diff --git a/src/app.rs b/src/app.rs index d9dd304..c2e3257 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,6 +54,7 @@ pub enum InputMode { FilterMode, CategoryNameInput, // For creating/renaming categories SelectDiscoveredFeed, // For picking from auto-discovered feeds + ArticleSearch, // In-article find: live query, n/N to jump, ESC to clear } #[derive(Clone, Debug, PartialEq)] @@ -175,6 +176,38 @@ pub struct App { /// evicted-and-recreated slot can't accidentally satisfy the new /// request — `record_extraction_result` matches the generation. pub next_extraction_generation: u64, + /// In-article find query. Persists across Enter/Esc transitions until + /// the user clears it (Esc while in ArticleSearch input mode) or moves + /// focus to a different article. + pub article_search_query: String, + /// Index of the currently-focused match within `article_search_matches`, + /// or `None` when there are no matches. + pub article_search_current: Option, + /// Matches cached by the detail renderer each frame for use by `n`/`N` + /// in event handling. Always re-derived from `article_body_cache` + + /// `article_search_query` at render time, so this list is consistent + /// with what's drawn on screen. + pub article_search_matches: Vec, + /// Cached body text fed to the detail view's `Paragraph`. Written by + /// the renderer every frame, read by `next_article_match` / + /// `prev_article_match` to compute wrapped-row scroll targets. + pub article_body_cache: String, + /// Cached content width (post-border, post-padding) used to compute + /// wrapped-row positions when jumping to a match. + pub article_body_width_cache: usize, +} + +/// A single in-article search match, indexed against the rendered body's +/// `'\n'`-separated logical lines (the same split the renderer uses to +/// build the `Vec`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArticleMatch { + /// 0-based index into `body.split('\n')`. + pub line: usize, + /// Byte offset of the match start within that line. + pub start: usize, + /// Byte offset of the match end (exclusive) within that line. + pub end: usize, } /// Snapshot of the focused article passed to template-expansion / pipe-payload helpers. @@ -467,6 +500,11 @@ impl App { last_detail_focus: None, pending_extraction_requests: VecDeque::new(), next_extraction_generation: 0, + article_search_query: String::new(), + article_search_current: None, + article_search_matches: Vec::new(), + article_body_cache: String::new(), + article_body_width_cache: 0, }; app.update_dashboard(); @@ -1515,12 +1553,74 @@ impl App { } } - /// Exit the detail view and reset scroll position + /// Exit the detail view and reset scroll position. Also resets + /// `input_mode` so an in-progress `ArticleSearch` session can't strand + /// the user in an input mode that no other view renders a cue for. pub fn exit_detail_view(&mut self, new_view: View) { self.detail_vertical_scroll = 0; + self.clear_article_search(); + self.input_mode = InputMode::Normal; self.view = new_view; } + /// Drop any in-article search state. Called when the focused article + /// changes, when the user presses `Esc` while typing a query, or when + /// the detail view is exited. Also drops the cached body snapshot so a + /// stale buffer can't feed a stray `n`/`N` press across an article + /// change — the renderer re-populates on the next frame if a query is + /// entered. `String::clear` keeps the allocation for reuse. + pub fn clear_article_search(&mut self) { + self.article_search_query.clear(); + self.article_search_current = None; + self.article_search_matches.clear(); + self.article_body_cache.clear(); + self.article_body_width_cache = 0; + } + + /// Advance the in-article match cursor by one and scroll the detail + /// viewport so the new match is visible. No-op when there are no + /// matches. Wraps around to 0 past the last match. + pub fn next_article_match(&mut self) { + if self.article_search_matches.is_empty() { + return; + } + let cur = self.article_search_current.unwrap_or(0); + let next = (cur + 1) % self.article_search_matches.len(); + self.article_search_current = Some(next); + self.scroll_to_current_article_match(); + } + + /// Move the in-article match cursor back by one. Wraps from 0 to the + /// last match. No-op when there are no matches. + pub fn prev_article_match(&mut self) { + if self.article_search_matches.is_empty() { + return; + } + let len = self.article_search_matches.len(); + let cur = self.article_search_current.unwrap_or(0); + let prev = if cur == 0 { len - 1 } else { cur - 1 }; + self.article_search_current = Some(prev); + self.scroll_to_current_article_match(); + } + + fn scroll_to_current_article_match(&mut self) { + let Some(idx) = self.article_search_current else { + return; + }; + let Some(m) = self.article_search_matches.get(idx) else { + return; + }; + // Leave two rows of context above the match so the user can read + // the paragraph it lives in, not just the match itself. + let row = wrapped_row_of_line( + &self.article_body_cache, + m.line, + self.article_body_width_cache, + ); + self.detail_vertical_scroll = row.saturating_sub(2); + // Final clamp happens on the next render via `clamp_detail_scroll`. + } + /// Check if auto-refresh should trigger pub fn should_auto_refresh(&self) -> bool { if self.refresh_in_progress { @@ -2113,11 +2213,112 @@ impl App { let current_focus = self.current_article_indices(); if self.last_detail_focus != current_focus { self.show_extracted = true; + // A `/foo` left over from the previous article would be + // misleading on a brand new body — drop it on focus change. + self.clear_article_search(); self.last_detail_focus = current_focus; } } } +/// Find all occurrences of `query` in `body` using ripgrep-style smart-case +/// matching: case-sensitive when `query` contains any uppercase character +/// (Unicode-aware), case-insensitive (ASCII fold) otherwise. Returns an +/// empty `Vec` for an empty query. Lines are indexed by `body.split('\n')` +/// so the result aligns with what the detail renderer draws. +/// +/// INVARIANT: the case fold used here MUST be byte-length-preserving so +/// that byte offsets within the folded haystack remain valid byte offsets +/// (and valid UTF-8 boundaries) within the original line — the detail +/// renderer slices the *original* line at `[start..end]` to build the +/// highlight span. `str::to_ascii_lowercase` satisfies this (it only +/// flips bit 5 of bytes in `0x41..=0x5A`, never altering byte length or +/// touching multi-byte UTF-8 sequences). Full Unicode case folding via +/// `str::to_lowercase` does NOT — e.g. `"İ".to_lowercase() == "i\u{307}"` +/// changes the byte length. Do not swap the fold without redesigning the +/// offset model (e.g. by maintaining a parallel folded→original byte map). +/// The side effect is that non-ASCII characters do not fold in +/// case-insensitive mode (e.g. `"café"` will not match `"Café"`); that's +/// the price of the invariant. +pub fn find_article_matches(body: &str, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let case_sensitive = query.chars().any(|c| c.is_uppercase()); + let needle = if case_sensitive { + query.to_string() + } else { + query.to_ascii_lowercase() + }; + let needle_len = needle.len(); + let mut out = Vec::new(); + for (line_idx, line) in body.split('\n').enumerate() { + if case_sensitive { + scan_line(line, &needle, needle_len, line_idx, &mut out); + } else { + let folded = line.to_ascii_lowercase(); + scan_line(&folded, &needle, needle_len, line_idx, &mut out); + } + } + out +} + +fn scan_line( + haystack: &str, + needle: &str, + needle_len: usize, + line_idx: usize, + out: &mut Vec, +) { + let mut start = 0; + while start <= haystack.len() { + let rest = &haystack[start..]; + match rest.find(needle) { + Some(rel) => { + let pos = start + rel; + let end = pos + needle_len; + out.push(ArticleMatch { + line: line_idx, + start: pos, + end, + }); + // Step past the match (guard against zero-length needle — + // we already filter that, but belt-and-braces). + start = end.max(pos + 1); + } + None => break, + } + } +} + +/// Cumulative wrapped-row count for source lines `0..target_line_idx`. +/// Matches the per-line walk in `count_wrapped_lines` so scroll positions +/// remain consistent with how `Paragraph` lays out the body. Returns 0 for +/// `target_line_idx == 0` or a zero `width`. +pub fn wrapped_row_of_line(body: &str, target_line_idx: usize, width: usize) -> u16 { + if width == 0 || target_line_idx == 0 { + return 0; + } + let mut row: u16 = 0; + for (i, line) in body.split('\n').enumerate() { + if i >= target_line_idx { + break; + } + if line.is_empty() { + row = row.saturating_add(1); + } else { + let lw = unicode_width::UnicodeWidthStr::width(line); + if lw == 0 { + row = row.saturating_add(1); + } else { + let wrapped = lw.div_ceil(width).max(1); + row = row.saturating_add(wrapped as u16); + } + } + } + row +} + /// Build an `ArticleContext` directly from a `Feed` + `FeedItem` pair — /// the path used by `exec_on_new`, where the new item may not yet live /// inside `App::feeds`. @@ -3678,4 +3879,279 @@ mod tests { "watchdog must NOT promote a Failed entry to MRU — would let it outlive newer Ready slots" ); } + + // ── In-article search helpers ───────────────────────────────────── + + #[test] + fn test_find_article_matches_empty_query_returns_empty() { + assert!(find_article_matches("anything goes here", "").is_empty()); + assert!(find_article_matches("", "").is_empty()); + } + + #[test] + fn test_find_article_matches_case_insensitive_when_query_all_lowercase() { + let body = "Foo bar FOO baz foo"; + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 3); + // All on line 0; starts at byte offsets 0, 8, 16. + assert_eq!( + matches[0], + ArticleMatch { + line: 0, + start: 0, + end: 3 + } + ); + assert_eq!( + matches[1], + ArticleMatch { + line: 0, + start: 8, + end: 11 + } + ); + assert_eq!( + matches[2], + ArticleMatch { + line: 0, + start: 16, + end: 19 + } + ); + } + + #[test] + fn test_find_article_matches_case_sensitive_when_query_has_uppercase() { + // Smart-case: "Foo" has uppercase → case-sensitive, so only the + // first occurrence matches (the FOO and foo variants are skipped). + let body = "Foo bar FOO baz foo"; + let matches = find_article_matches(body, "Foo"); + assert_eq!(matches.len(), 1); + assert_eq!( + matches[0], + ArticleMatch { + line: 0, + start: 0, + end: 3 + } + ); + } + + #[test] + fn test_find_article_matches_across_multiple_lines() { + let body = "first line\nsecond foo line\nthird foo and foo again"; + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 3); + assert_eq!(matches[0].line, 1); + assert_eq!(matches[0].start, 7); + assert_eq!(matches[1].line, 2); + assert_eq!(matches[1].start, 6); + assert_eq!(matches[2].line, 2); + assert_eq!(matches[2].start, 14); + } + + #[test] + fn test_find_article_matches_no_matches_returns_empty() { + assert!(find_article_matches("hello world", "xyz").is_empty()); + } + + #[test] + fn test_wrapped_row_of_line_zero_returns_zero() { + let body = "one\ntwo\nthree"; + assert_eq!(wrapped_row_of_line(body, 0, 80), 0); + } + + #[test] + fn test_wrapped_row_of_line_short_lines_one_row_each() { + let body = "one\ntwo\nthree\nfour"; + // Line 2 ("three") starts at row 2 — preceded by "one" (1 row) and + // "two" (1 row). + assert_eq!(wrapped_row_of_line(body, 2, 80), 2); + assert_eq!(wrapped_row_of_line(body, 3, 80), 3); + } + + #[test] + fn test_wrapped_row_of_line_accounts_for_wrap() { + // 12-char line in a 5-wide viewport wraps to ceil(12/5) = 3 rows. + let body = "aaaaaaaaaaaa\nshort"; + assert_eq!(wrapped_row_of_line(body, 1, 5), 3); + } + + #[test] + fn test_wrapped_row_of_line_empty_width_returns_zero() { + assert_eq!(wrapped_row_of_line("foo\nbar", 1, 0), 0); + } + + #[test] + fn test_next_article_match_wraps_and_advances() { + let mut app = App::new(); + app.article_body_cache = "alpha\nbeta\nalpha".to_string(); + app.article_body_width_cache = 80; + app.article_search_query = "alpha".to_string(); + app.article_search_matches = + find_article_matches(&app.article_body_cache, &app.article_search_query); + assert_eq!(app.article_search_matches.len(), 2); + app.article_search_current = Some(0); + + app.next_article_match(); + assert_eq!(app.article_search_current, Some(1)); + // Wraps to 0 past the end. + app.next_article_match(); + assert_eq!(app.article_search_current, Some(0)); + } + + #[test] + fn test_prev_article_match_wraps_to_last() { + let mut app = App::new(); + app.article_body_cache = "alpha\nalpha\nalpha".to_string(); + app.article_body_width_cache = 80; + app.article_search_query = "alpha".to_string(); + app.article_search_matches = + find_article_matches(&app.article_body_cache, &app.article_search_query); + app.article_search_current = Some(0); + + app.prev_article_match(); + assert_eq!(app.article_search_current, Some(2)); + app.prev_article_match(); + assert_eq!(app.article_search_current, Some(1)); + } + + #[test] + fn test_next_article_match_noop_when_empty() { + let mut app = App::new(); + app.article_search_current = None; + app.next_article_match(); + assert_eq!(app.article_search_current, None); + app.prev_article_match(); + assert_eq!(app.article_search_current, None); + } + + #[test] + fn test_clear_article_search_resets_all_fields() { + let mut app = App::new(); + app.article_search_query = "foo".to_string(); + app.article_search_current = Some(2); + app.article_search_matches = vec![ArticleMatch { + line: 0, + start: 0, + end: 3, + }]; + app.article_body_cache = "stale body content".to_string(); + app.article_body_width_cache = 80; + app.clear_article_search(); + assert!(app.article_search_query.is_empty()); + assert_eq!(app.article_search_current, None); + assert!(app.article_search_matches.is_empty()); + // Body cache must be dropped too — otherwise a stray n/N after the + // user clears (then re-typed something on a different article) could + // scroll-target against the previous article's text. + assert!( + app.article_body_cache.is_empty(), + "clear_article_search must drop the body cache" + ); + assert_eq!(app.article_body_width_cache, 0); + } + + #[test] + fn test_exit_detail_view_clears_article_search() { + let mut app = App::new(); + app.article_search_query = "foo".to_string(); + app.detail_vertical_scroll = 42; + app.exit_detail_view(View::FeedItems); + assert!(app.article_search_query.is_empty()); + assert_eq!(app.detail_vertical_scroll, 0); + assert_eq!(app.view, View::FeedItems); + } + + #[test] + fn test_exit_detail_view_resets_input_mode() { + // A user typing in the search footer when something else triggers + // exit_detail_view (e.g. a macro/remote, or any future code path) + // must not be stranded in `ArticleSearch` mode in a different view + // that has no footer to surface their input. + let mut app = App::new(); + app.input_mode = InputMode::ArticleSearch; + app.article_search_query = "foo".to_string(); + app.exit_detail_view(View::FeedItems); + assert_eq!(app.input_mode, InputMode::Normal); + } + + #[test] + fn test_find_article_matches_non_ascii_case_insensitive_does_not_fold() { + // INVARIANT: the ASCII fold leaves non-ASCII bytes untouched, so + // an uppercase non-ASCII letter in the body cannot be matched by + // the equivalent lowercase non-ASCII letter in the query. Here + // body is "É" (U+00C9, bytes 0xC3 0x89) and query is "é" (U+00E9, + // bytes 0xC3 0xA9): different bytes, no match. (Sanity-check the + // ASCII path still folds in the same scenario — `C` vs `c` *does* + // match because both bytes are in the A-Z fold table.) + // + // This test pins the v1 behavior so a future change to + // Unicode-aware folding is intentional — `str::to_lowercase` is + // NOT byte-length-preserving and would silently invalidate the + // byte-offset-into-original-line model the highlight render relies + // on. See the doc comment on `find_article_matches`. + let matches = find_article_matches("É", "é"); + assert!( + matches.is_empty(), + "ASCII-fold smart-case must not match non-ASCII case pairs — \ + changing this requires redesigning the byte-offset model" + ); + + // ASCII counterpart — same shape (uppercase in body, lowercase in + // query, no uppercase in query so case-insensitive mode applies) — + // *does* match. Demonstrates the fold is doing its ASCII job; what + // doesn't fold above is exclusively the non-ASCII range. + let matches = find_article_matches("C", "c"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, 0); + assert_eq!(matches[0].end, 1); + + // And — to lock in the *positive* path for the non-ASCII byte we + // care about — a query whose bytes already match the body matches + // normally regardless of fold mode. + let matches = find_article_matches("Café", "café"); + assert_eq!( + matches.len(), + 1, + "lowercase non-ASCII in both needle and folded haystack must \ + still match — only the cross-case pair is the gap" + ); + assert_eq!(matches[0].start, 0); + assert_eq!(matches[0].end, "Café".len()); + } + + #[test] + fn test_find_article_matches_offsets_valid_with_multibyte_chars() { + // Multi-byte char ("é" = 2 bytes in UTF-8) sits in the line before + // the ASCII match. The reported byte offsets must land on UTF-8 + // boundaries in the *original* line, not just the folded one, so + // that `&line[start..end]` slicing in `build_styled_body` is safe. + let body = "café bar foo baz"; + // bytes: c(0) a(1) f(2) é(3..5) ' '(5) b(6) a(7) r(8) ' '(9) f(10) o(11) o(12) + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].line, 0); + assert_eq!(matches[0].start, 10); + assert_eq!(matches[0].end, 13); + + // Sanity: slicing the original line at the reported offsets must + // succeed and yield exactly "foo". If `to_ascii_lowercase` ever + // disturbed multi-byte sequences (it doesn't, per docs) this would + // panic with a UTF-8 boundary error. + assert_eq!(&body[matches[0].start..matches[0].end], "foo"); + } + + #[test] + fn test_find_article_matches_overlapping_needle_advances_past_match() { + // Needle "aa" in "aaaa" yields 2 non-overlapping matches at [0..2] + // and [2..4] — `scan_line` must step past `end` so we never report + // [0..2], [1..3], [2..4] (which would render as nested highlights). + let matches = find_article_matches("aaaa", "aa"); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].start, 0); + assert_eq!(matches[0].end, 2); + assert_eq!(matches[1].start, 2); + assert_eq!(matches[1].end, 4); + } } diff --git a/src/events.rs b/src/events.rs index bd2998a..97cbca4 100644 --- a/src/events.rs +++ b/src/events.rs @@ -836,8 +836,19 @@ pub(crate) fn handle_key_event(app: &mut App, key: crossterm::event::KeyEvent) - _ if app.key_matches(KeyAction::ToggleRead, &key) => { handle_toggle_read_current(app); } - _ if app.key_matches(KeyAction::OpenSearch, &key) => { - handle_open_search(app); + // In-article find: `/` enters ArticleSearch input mode, + // n/N jump between matches (handled here so they're no-ops + // when no query is active rather than swallowing the key). + // Checked before `OpenSearch` so `/` in detail view means + // article-search, not cross-feed search. + _ if app.key_matches(KeyAction::OpenArticleSearch, &key) => { + app.input_mode = InputMode::ArticleSearch; + } + _ if app.key_matches(KeyAction::NextMatch, &key) => { + app.next_article_match(); + } + _ if app.key_matches(KeyAction::PrevMatch, &key) => { + app.prev_article_match(); } _ if app.key_matches(KeyAction::Help, &key) => { handle_show_help(app); @@ -1198,6 +1209,30 @@ pub(crate) fn handle_key_event(app: &mut App, key: crossterm::event::KeyEvent) - } _ => {} }, + // In-article find. Live updates: matches and highlights are + // re-derived from `article_search_query` by the detail renderer + // every frame, so the keypress just mutates the query and lets the + // next render do the rest. `Enter` commits (keeps highlights); + // `Esc` clears (no highlights, no footer). `current` is reset on + // edit so the next `n` lands on the first match of the new query. + InputMode::ArticleSearch => match key.code { + KeyCode::Enter => { + app.input_mode = InputMode::Normal; + } + KeyCode::Esc => { + app.clear_article_search(); + app.input_mode = InputMode::Normal; + } + KeyCode::Char(c) => { + app.article_search_query.push(c); + app.article_search_current = None; + } + KeyCode::Backspace => { + app.article_search_query.pop(); + app.article_search_current = None; + } + _ => {} + }, InputMode::FilterMode => match key.code { KeyCode::Esc => { app.filter_mode = false; @@ -2113,4 +2148,143 @@ mod tests { ); assert!(app.error.is_none(), "should not error: {:?}", app.error); } + + // ── In-article search ────────────────────────────────────────────── + + /// Set up an app sitting in the detail view with a focused article so + /// the in-article search bindings are reachable. Returns it ready for + /// keypress simulation. + fn make_detail_app() -> App { + let mut app = make_test_app(); + app.view = View::FeedItemDetail; + app.selected_feed = Some(0); + app.selected_item = Some(0); + app + } + + #[test] + fn test_slash_in_detail_view_enters_article_search_mode() { + let mut app = make_detail_app(); + let slash = make_key(KeyCode::Char('/'), KeyModifiers::NONE); + let _ = handle_key_event(&mut app, slash).unwrap(); + assert_eq!(app.input_mode, InputMode::ArticleSearch); + } + + #[test] + fn test_article_search_mode_typing_builds_query() { + let mut app = make_detail_app(); + app.input_mode = InputMode::ArticleSearch; + + for c in "foo".chars() { + let _ = + handle_key_event(&mut app, make_key(KeyCode::Char(c), KeyModifiers::NONE)).unwrap(); + } + assert_eq!(app.article_search_query, "foo"); + // Current-match cursor is reset on edit; renderer will seed it. + assert_eq!(app.article_search_current, None); + } + + #[test] + fn test_article_search_backspace_shrinks_query() { + let mut app = make_detail_app(); + app.input_mode = InputMode::ArticleSearch; + app.article_search_query = "foo".to_string(); + + let _ = + handle_key_event(&mut app, make_key(KeyCode::Backspace, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.article_search_query, "fo"); + } + + #[test] + fn test_article_search_enter_keeps_query_returns_to_normal() { + let mut app = make_detail_app(); + app.input_mode = InputMode::ArticleSearch; + app.article_search_query = "foo".to_string(); + + let _ = handle_key_event(&mut app, make_key(KeyCode::Enter, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!( + app.article_search_query, "foo", + "Enter must preserve the query so highlights persist" + ); + } + + #[test] + fn test_article_search_esc_clears_query_returns_to_normal() { + let mut app = make_detail_app(); + app.input_mode = InputMode::ArticleSearch; + app.article_search_query = "foo".to_string(); + app.article_search_current = Some(2); + + let _ = handle_key_event(&mut app, make_key(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.article_search_query.is_empty()); + assert_eq!(app.article_search_current, None); + } + + #[test] + fn test_n_in_detail_view_advances_match_cursor() { + use crate::app::ArticleMatch; + + let mut app = make_detail_app(); + // Simulate post-render state: matches list and body cache populated. + app.article_search_query = "foo".to_string(); + app.article_search_matches = vec![ + ArticleMatch { + line: 0, + start: 0, + end: 3, + }, + ArticleMatch { + line: 1, + start: 4, + end: 7, + }, + ]; + app.article_body_cache = "foo line\nbar foo line".to_string(); + app.article_body_width_cache = 80; + app.article_search_current = Some(0); + + let n = make_key(KeyCode::Char('n'), KeyModifiers::NONE); + let _ = handle_key_event(&mut app, n).unwrap(); + assert_eq!(app.article_search_current, Some(1)); + } + + #[test] + fn test_capital_n_in_detail_view_moves_back() { + use crate::app::ArticleMatch; + + let mut app = make_detail_app(); + app.article_search_query = "foo".to_string(); + app.article_search_matches = vec![ + ArticleMatch { + line: 0, + start: 0, + end: 3, + }, + ArticleMatch { + line: 1, + start: 4, + end: 7, + }, + ]; + app.article_body_cache = "foo line\nbar foo line".to_string(); + app.article_body_width_cache = 80; + app.article_search_current = Some(1); + + let big_n = make_key(KeyCode::Char('N'), KeyModifiers::SHIFT); + let _ = handle_key_event(&mut app, big_n).unwrap(); + assert_eq!(app.article_search_current, Some(0)); + } + + #[test] + fn test_back_from_detail_clears_article_search() { + let mut app = make_detail_app(); + app.article_search_query = "foo".to_string(); + + // `h` is Back by default. + let back = make_key(KeyCode::Char('h'), KeyModifiers::NONE); + let _ = handle_key_event(&mut app, back).unwrap(); + assert!(app.article_search_query.is_empty()); + } } diff --git a/src/keybindings.rs b/src/keybindings.rs index 8d6e72c..f2166cb 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -102,6 +102,10 @@ pub enum KeyAction { FetchFullText, ScrollPreviewUp, ScrollPreviewDown, + // In-article find (detail view only) + OpenArticleSearch, + NextMatch, + PrevMatch, // Tree ToggleExpand, // Tab @@ -144,6 +148,9 @@ impl KeyAction { Self::FetchFullText => "fetch-full-text", Self::ScrollPreviewUp => "scroll-preview-up", Self::ScrollPreviewDown => "scroll-preview-down", + Self::OpenArticleSearch => "open-article-search", + Self::NextMatch => "next-match", + Self::PrevMatch => "prev-match", Self::ToggleExpand => "toggle-expand", Self::NextTab => "next-tab", Self::PrevTab => "prev-tab", @@ -186,6 +193,9 @@ impl FromStr for KeyAction { "fetch_full_text" => Ok(Self::FetchFullText), "scroll_preview_up" => Ok(Self::ScrollPreviewUp), "scroll_preview_down" => Ok(Self::ScrollPreviewDown), + "open_article_search" => Ok(Self::OpenArticleSearch), + "next_match" => Ok(Self::NextMatch), + "prev_match" => Ok(Self::PrevMatch), "toggle_expand" => Ok(Self::ToggleExpand), "next_tab" => Ok(Self::NextTab), "prev_tab" => Ok(Self::PrevTab), @@ -378,6 +388,21 @@ pub fn default_keybindings() -> KeyBindingMap { ], ); + // In-article find (detail view). `/` is also `OpenSearch` globally, + // but the detail-view event arm checks `OpenArticleSearch` first. + map.insert( + KeyAction::OpenArticleSearch, + vec![KeyBinding::new(KeyCode::Char('/'))], + ); + map.insert( + KeyAction::NextMatch, + vec![KeyBinding::new(KeyCode::Char('n'))], + ); + map.insert( + KeyAction::PrevMatch, + vec![KeyBinding::new(KeyCode::Char('N'))], + ); + // Tree map.insert( KeyAction::ToggleExpand, @@ -1127,6 +1152,9 @@ mod tests { KeyAction::FetchFullText, KeyAction::ScrollPreviewUp, KeyAction::ScrollPreviewDown, + KeyAction::OpenArticleSearch, + KeyAction::NextMatch, + KeyAction::PrevMatch, KeyAction::ToggleExpand, KeyAction::NextTab, KeyAction::PrevTab, diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 02afa00..8c46aa8 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -1,4 +1,4 @@ -use crate::app::{App, ExtractionState}; +use crate::app::{find_article_matches, App, ArticleMatch, ExtractionState, InputMode}; use crate::keybindings::{key_display, KeyAction}; use crate::ui::utils::{count_wrapped_lines, format_content_for_reading, truncate_url}; use crate::ui::ColorScheme; @@ -35,15 +35,34 @@ pub(super) fn render_item_detail( // call below would conflict). Logic lives on `App` so it's // unit-testable without spinning up a TestBackend. app.sync_detail_focus_anchor(); + // The search footer is visible whenever the user is actively typing a + // query OR a committed query has highlights still pinned. Computing + // this once up front lets the layout split, footer render, and title + // suffix all agree on visibility. + let search_footer_visible = + matches!(app.input_mode, InputMode::ArticleSearch) || !app.article_search_query.is_empty(); if let Some(item) = app.current_item() { - // Split the area into header and content with better proportions - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(9), // Header - increased for better spacing - Constraint::Min(0), // Content - ]) - .split(area); + // Split the area into header, content, and (optionally) a 1-row + // search footer. The footer is omitted when in-article search is + // inactive so quiet reading is unchanged. + let chunks = if search_footer_visible { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(9), // Header + Constraint::Min(0), // Content + Constraint::Length(1), // Search footer + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(9), // Header + Constraint::Min(0), // Content + ]) + .split(area) + }; // Create header with enhanced typography let mut header_lines = vec![ @@ -221,6 +240,52 @@ pub(super) fn render_item_detail( app.update_detail_max_scroll(content_lines, viewport_height); app.clamp_detail_scroll(); + // Search work is skipped entirely when the user isn't typing a + // query and has no committed query pinned — keeps the steady-state + // detail view allocation-free. Without this gate the renderer + // would clone the full body string into `article_body_cache` every + // frame (default tick rate 100ms ⇒ ~500KB/s churn on a 50KB + // full-text article) just to back a feature that wasn't engaged. + let search_active = search_footer_visible; + + let (matches, current_idx) = if search_active { + // Cache the rendered body and content width so `n` / `N` + // (handled in event dispatch) can resolve a match line back to + // a wrapped-row scroll target without re-deriving the body + // source. Re-derivation here is the source of truth — these + // writes happen once per frame the search is live. + app.article_body_cache.clear(); + app.article_body_cache.push_str(&description); + app.article_body_width_cache = content_width; + + // Recompute matches against the freshly rendered body. Clamp + // the current-match cursor so it stays valid as the query + // shrinks or the body changes (e.g. after `Shift+F` toggles + // full-text). The clamp lands the cursor on *some* match, not + // necessarily the "same" one — the i-th match before a body + // change is rarely the i-th match after — but landing on a + // valid match beats resetting to 0 every keystroke. + let matches = find_article_matches(&description, &app.article_search_query); + if matches.is_empty() { + app.article_search_current = None; + } else { + let cur = app + .article_search_current + .unwrap_or(0) + .min(matches.len() - 1); + app.article_search_current = Some(cur); + } + let current_idx = app.article_search_current; + app.article_search_matches = matches.clone(); + (matches, current_idx) + } else { + // Search inactive: ensure stale state can't satisfy a future + // `n`/`N` press, then skip the work. + app.article_search_matches.clear(); + app.article_search_current = None; + (Vec::new(), None) + }; + // Create theme-specific scroll indicator let scroll_arrows = if colors.border_normal == BorderType::Double { ("▼", "▲") // Dark: solid arrows @@ -235,25 +300,50 @@ pub(super) fn render_item_detail( BodySource::Failed(_) => "Full-text failed", }; + let match_suffix = if app.article_search_query.is_empty() { + String::new() + } else if matches.is_empty() { + " · no matches".to_string() + } else { + format!( + " · {}/{} matches", + current_idx.map(|i| i + 1).unwrap_or(0), + matches.len() + ) + }; + let scroll_indicator = if app.detail_max_scroll > 0 { let scroll_pct = (app.detail_vertical_scroll as f32 / app.detail_max_scroll as f32 * 100.0) as u16; if app.detail_vertical_scroll == 0 { format!( - " {} {} · Scroll {} for more ", - article_icon, body_label, scroll_arrows.0 + " {} {} · Scroll {} for more{} ", + article_icon, body_label, scroll_arrows.0, match_suffix ) } else if app.detail_vertical_scroll >= app.detail_max_scroll { - format!(" {} {} · End of article ", article_icon, body_label) + format!( + " {} {} · End of article{} ", + article_icon, body_label, match_suffix + ) } else { - format!(" {} {} · {}% ", article_icon, body_label, scroll_pct) + format!( + " {} {} · {}%{} ", + article_icon, body_label, scroll_pct, match_suffix + ) } } else { - format!(" {} {} ", article_icon, body_label) + format!(" {} {}{} ", article_icon, body_label, match_suffix) }; + // Build the rendered body as styled lines so match ranges can be + // highlighted. `Wrap { trim: true }` on the Paragraph preserves + // intra-line span styling under soft wrap, so we don't need to + // pre-wrap ourselves. Lines without matches collapse to a single + // `Span::raw` to avoid unnecessary allocations. + let body_lines = build_styled_body(&description, &matches, current_idx, colors); + // Create content paragraph with theme-specific styling - let content = Paragraph::new(description) + let content = Paragraph::new(body_lines) .block( Block::default() .title(scroll_indicator) @@ -270,5 +360,254 @@ pub(super) fn render_item_detail( .alignment(Alignment::Left); f.render_widget(content, chunks[1]); + + if search_footer_visible { + render_search_footer(f, app, chunks[2], colors); + } + } +} + +/// Render the 1-row search footer at the bottom of the detail view. +/// Shows `/` and (when not actively typing) the match indicator. +/// Sets the terminal cursor on the line when in `ArticleSearch` input +/// mode so the user sees where input is going. +fn render_search_footer(f: &mut Frame, app: &App, area: Rect, colors: &ColorScheme) { + let typing = matches!(app.input_mode, InputMode::ArticleSearch); + let match_count = app.article_search_matches.len(); + let current = app.article_search_current; + + let mut spans = vec![ + Span::styled( + "/", + Style::default() + .fg(colors.highlight) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + app.article_search_query.clone(), + Style::default().fg(colors.text), + ), + ]; + + if !typing { + let status = if app.article_search_query.is_empty() { + String::new() + } else if match_count == 0 { + " no matches".to_string() + } else { + format!( + " {}/{} (n/N to jump · ESC to clear)", + current.map(|i| i + 1).unwrap_or(0), + match_count + ) + }; + if !status.is_empty() { + spans.push(Span::styled(status, Style::default().fg(colors.muted))); + } + } + + let footer = Paragraph::new(Line::from(spans)) + .style(Style::default().bg(colors.surface)) + .alignment(Alignment::Left); + f.render_widget(footer, area); + + if typing { + // Cursor sits just past the rendered query: 1 column for "/" plus + // the display width of the query. Clamp to the visible row width. + let q_width = + unicode_width::UnicodeWidthStr::width(app.article_search_query.as_str()) as u16; + let cursor_x = area.x + 1 + q_width; + f.set_cursor(cursor_x.min(area.x + area.width.saturating_sub(1)), area.y); + } +} + +/// Build the body as styled `Line`s, applying highlight spans for each +/// match. The match at `current_idx` (when set) is rendered with a +/// stronger style so the user can tell which one `n`/`N` will move from. +/// +/// Lifetimes: the returned `Line`s borrow from `body` directly — unmatched +/// segments and unmatched whole lines are zero-copy `Span::raw(&str)` / +/// `Line::raw(&str)`. Only the very thin set of highlight spans need +/// distinct styling and even those borrow their text from `body`. +pub(super) fn build_styled_body<'a>( + body: &'a str, + matches: &[ArticleMatch], + current_idx: Option, + colors: &ColorScheme, +) -> Vec> { + // Non-current matches: foreground tinted only, no background fill, no + // bold — present but quiet. The match the `n` cursor sits on gets the + // attention: filled `highlight` background, surface-colored text, bold. + let highlight_style = Style::default().fg(colors.highlight); + let current_style = Style::default() + .bg(colors.highlight) + .fg(colors.surface) + .add_modifier(Modifier::BOLD); + + // `find_article_matches` already emits matches in line-major order + // (outer = lines, inner = positions within a line). Walk both lines + // and matches with a single cursor instead of bucketing into a + // HashMap — saves the allocation and keeps the data flow obvious. + let mut match_cursor = 0usize; + + body.split('\n') + .enumerate() + .map(|(line_idx, line)| { + // Skip any matches whose line index is behind us (should not + // happen in practice, but defensive against future callers). + while match_cursor < matches.len() && matches[match_cursor].line < line_idx { + match_cursor += 1; + } + // Find the contiguous slice of matches that belong to this line. + let line_start = match_cursor; + while match_cursor < matches.len() && matches[match_cursor].line == line_idx { + match_cursor += 1; + } + let line_matches = &matches[line_start..match_cursor]; + + if line_matches.is_empty() { + // `Line::from(&'a str)` borrows the slice rather than + // copying it (`Line::raw` isn't available in ratatui 0.23). + Line::from(line) + } else { + let mut spans: Vec> = Vec::with_capacity(line_matches.len() * 2 + 1); + let mut col = 0usize; + for (offset, m) in line_matches.iter().enumerate() { + if m.start > col { + spans.push(Span::raw(&line[col..m.start])); + } + let style = if Some(line_start + offset) == current_idx { + current_style + } else { + highlight_style + }; + spans.push(Span::styled(&line[m.start..m.end], style)); + col = m.end; + } + if col < line.len() { + spans.push(Span::raw(&line[col..])); + } + Line::from(spans) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::find_article_matches; + + /// Extract the printable text of a `Line` so tests can assert content + /// independent of span styling. Joins all span text in order. + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn build_styled_body_no_matches_returns_one_raw_line_per_source_line() { + let colors = ColorScheme::dark(); + let body = "alpha\nbeta\ngamma"; + let lines = build_styled_body(body, &[], None, &colors); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].spans.len(), 1); + assert_eq!(line_text(&lines[0]), "alpha"); + assert_eq!(line_text(&lines[1]), "beta"); + assert_eq!(line_text(&lines[2]), "gamma"); + } + + #[test] + fn build_styled_body_highlights_match_and_preserves_surrounding_text() { + let colors = ColorScheme::dark(); + let body = "before foo after"; + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 1); + + let lines = build_styled_body(body, &matches, Some(0), &colors); + assert_eq!(lines.len(), 1); + + // Expected span sequence: "before ", "foo" (styled), " after". + let spans = &lines[0].spans; + assert_eq!(spans.len(), 3); + assert_eq!(spans[0].content.as_ref(), "before "); + assert_eq!(spans[1].content.as_ref(), "foo"); + assert_eq!(spans[2].content.as_ref(), " after"); + + // The "current" match must use the filled-background style, not + // the foreground-only style — a regression here would mean `n`/`N` + // gives no visual signal of which match is focused. + let current_style = Style::default() + .bg(colors.highlight) + .fg(colors.surface) + .add_modifier(Modifier::BOLD); + assert_eq!(spans[1].style, current_style); + } + + #[test] + fn build_styled_body_non_current_match_uses_quiet_style() { + let colors = ColorScheme::dark(); + let body = "foo and foo"; + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 2); + + // Cursor on match 0; match 1 must render with the quiet style. + let lines = build_styled_body(body, &matches, Some(0), &colors); + let spans = &lines[0].spans; + // Expect: "foo" (current), " and ", "foo" (quiet) + assert_eq!(spans.len(), 3); + let current_style = Style::default() + .bg(colors.highlight) + .fg(colors.surface) + .add_modifier(Modifier::BOLD); + let quiet_style = Style::default().fg(colors.highlight); + assert_eq!(spans[0].style, current_style); + assert_eq!(spans[2].style, quiet_style); + } + + #[test] + fn build_styled_body_handles_matches_across_multiple_lines() { + let colors = ColorScheme::dark(); + let body = "first foo line\nsecond foo line"; + let matches = find_article_matches(body, "foo"); + assert_eq!(matches.len(), 2); + + let lines = build_styled_body(body, &matches, Some(1), &colors); + assert_eq!(lines.len(), 2); + // Each line has 3 spans: prefix raw, match, suffix raw. + assert_eq!(lines[0].spans.len(), 3); + assert_eq!(lines[1].spans.len(), 3); + // The "current" highlight must be on line 1 (idx 1), not line 0. + let current_style = Style::default() + .bg(colors.highlight) + .fg(colors.surface) + .add_modifier(Modifier::BOLD); + assert_eq!(lines[0].spans[1].style.bg, None); + assert_eq!(lines[1].spans[1].style, current_style); + } + + #[test] + fn build_styled_body_match_at_line_start_emits_no_empty_prefix_span() { + let colors = ColorScheme::dark(); + let body = "foo tail"; + let matches = find_article_matches(body, "foo"); + let lines = build_styled_body(body, &matches, Some(0), &colors); + let spans = &lines[0].spans; + // Two spans: styled "foo", raw " tail" — no leading empty Span::raw. + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].content.as_ref(), "foo"); + assert_eq!(spans[1].content.as_ref(), " tail"); + } + + #[test] + fn build_styled_body_match_at_line_end_emits_no_trailing_empty_span() { + let colors = ColorScheme::dark(); + let body = "head foo"; + let matches = find_article_matches(body, "foo"); + let lines = build_styled_body(body, &matches, Some(0), &colors); + let spans = &lines[0].spans; + // Two spans: raw "head ", styled "foo" — no trailing empty Span::raw. + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].content.as_ref(), "head "); + assert_eq!(spans[1].content.as_ref(), "foo"); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bacf07a..0bbaa20 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -563,7 +563,7 @@ fn render_help_bar(f: &mut Frame, app: &App, area: Rect, colors: } View::FeedItemDetail => { format!( - "{}/{}: Scroll | {}/{}: Fast scroll | {}: Open | {}: Star | {}: Toggle read | {}: Links | {}: Full-text | {}: Search | {}: Theme | {}: Back | {}: Quit", + "{}/{}: Scroll | {}/{}: Fast scroll | {}: Open | {}: Star | {}: Toggle read | {}: Links | {}: Full-text | {}: Find | {}/{}: Next/Prev match | {}: Theme | {}: Back | {}: Quit", key_display(&KeyAction::MoveUp, &app.keybindings), key_display(&KeyAction::MoveDown, &app.keybindings), key_display(&KeyAction::PageUp, &app.keybindings), @@ -573,7 +573,9 @@ fn render_help_bar(f: &mut Frame, app: &App, area: Rect, colors: key_display(&KeyAction::ToggleRead, &app.keybindings), key_display(&KeyAction::ExtractLinks, &app.keybindings), key_display(&KeyAction::FetchFullText, &app.keybindings), - key_display(&KeyAction::OpenSearch, &app.keybindings), + key_display(&KeyAction::OpenArticleSearch, &app.keybindings), + key_display(&KeyAction::NextMatch, &app.keybindings), + key_display(&KeyAction::PrevMatch, &app.keybindings), key_display(&KeyAction::ToggleTheme, &app.keybindings), key_display(&KeyAction::Back, &app.keybindings), key_display(&KeyAction::ForceQuit, &app.keybindings), @@ -618,6 +620,11 @@ fn render_help_bar(f: &mut Frame, app: &App, area: Rect, colors: "j/k: Navigate | Enter: Select feed | Esc: Cancel".to_string(), Style::default().fg(colors.highlight), ), + InputMode::ArticleSearch => ( + "Type to search article (live) | ENTER: keep highlights | n/N: next/prev | ESC: cancel" + .to_string(), + Style::default().fg(colors.highlight), + ), }; // Only show help bar in normal mode @@ -714,13 +721,16 @@ fn render_compact_help_bar( kd(&KeyAction::OpenSearch), ), View::FeedItemDetail => format!( - "{}:back {}:scroll {}:open {}:star {}:read {}:fulltext", + "{}:back {}:scroll {}:open {}:star {}:read {}:fulltext {}:find {}/{}:match", kd(&KeyAction::Quit), kd(&KeyAction::MoveDown), kd(&KeyAction::OpenInBrowser), kd(&KeyAction::ToggleStar), kd(&KeyAction::ToggleRead), kd(&KeyAction::FetchFullText), + kd(&KeyAction::OpenArticleSearch), + kd(&KeyAction::NextMatch), + kd(&KeyAction::PrevMatch), ), View::CategoryManagement => format!("{}:back n:new e:edit d:del", kd(&KeyAction::Quit),), View::Starred => format!(