Skip to content

feat(detail): in-article find with smart-case matching#44

Merged
bahdotsh merged 2 commits into
mainfrom
feat/in-article-search
May 15, 2026
Merged

feat(detail): in-article find with smart-case matching#44
bahdotsh merged 2 commits into
mainfrom
feat/in-article-search

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

Summary

  • Adds /-style find-in-page to the article detail view: live highlights as you type, n/N to cycle matches with wrap-around, ESC to clear.
  • Works against both the feed-provided summary and the Readability-extracted full text — the query survives Shift+F toggling, dies on article focus change.
  • Ripgrep-style smart-case matching: case-sensitive iff the query has any uppercase character, ASCII-fold case-insensitive otherwise.
  • Repurposes / in the detail view from cross-feed search (which made little sense from inside an article) to article search.
  • New KeyActions: OpenArticleSearch (/), NextMatch (n), PrevMatch (N). All remappable via the existing [keybindings] config.

Design notes

  • Body is rendered as a styled Vec<Line> so Paragraph::wrap keeps doing soft-wrap for free; match ranges are emitted as styled spans within each source line.
  • Current match (the one n cursor sits on) gets the bold filled block; non-current matches stay subtle (foreground tint, no fill) so a heavily-matched page doesn't look like a Christmas tree.
  • Jump-to-match uses a sibling helper to count_wrapped_lines (wrapped_row_of_line) that returns the cumulative wrapped-row index for a given source line, so scrolling lands exactly where the wrapped output puts the match.
  • Body text + content width cached on App at render time so n/N event handling can resolve match → scroll without re-deriving the body source. State is in-memory only — nothing persisted.
  • Footer prompt is a 1-row split off the bottom of the content area, only carved when the search is actually in use; quiet reading is unchanged.

Test plan

  • cargo build --release — clean
  • cargo fmt --all -- --check — clean
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cargo test --all-features199 lib tests + 4 integration tests pass (22 new tests added: pure helpers, scroll math, state machine, and event-dispatch mode transitions)
  • Manual: /foo highlights, n/N cycle and viewport scrolls, ESC clears, Shift+F keeps the query alive on the same article, navigating to a different article drops the query
  • Manual: query with uppercase (e.g. Foo) only matches case-sensitively; all-lowercase matches case-insensitively

bahdotsh added 2 commits May 15, 2026 18:32
Full-text extraction landed in v0.7.0 (#43), which is great right
up until you open a 5000-word article and want to find a specific
phrase. Then it's just you, j/k, and your patience.

Before this commit `/` in the detail view opened the *cross-feed*
search modal, which made approximately zero sense — you're staring
at one article and pressing slash drops a full-screen overlay that
filters your entire bookmark list. Let's fix that.

Add a real find-in-page: `/` enters a new `ArticleSearch` input
mode, live-highlights matches as you type, `n`/`N` cycle through
them with wrap-around, and `ESC` clears. The query persists across
`Shift+F` toggling between summary and extracted body, so the same
search works whether you're reading the feed-provided HTML or the
Readability-extracted plain text. Focus change to a different
article drops the query — a stale `/foo` following you to the next
article is the kind of UX that makes people uninstall things.

Matching is ripgrep-style smart-case: case-sensitive when the
query has any uppercase character, case-insensitive (ASCII fold)
otherwise. Highlights render as styled spans inside a `Vec<Line>`
fed to the existing `Paragraph` widget, so soft-wrap continues to
work for free. The current match (the one `n` lands on) gets the
filled-block treatment; non-current matches stay subtle so the
page doesn't look like a Christmas tree.

Jump-to-match uses a sibling helper to `count_wrapped_lines` that
returns the cumulative wrapped-row index for a given source line,
so the viewport scrolls exactly where the wrapped output puts the
match — no drift, no math at three different abstraction layers.
Body and content-width are cached on `App` at render time so the
event handler can resolve match → scroll position without
re-deriving the body. State lives only in `App`; nothing persisted.

22 new tests cover the helpers, the state machine, and the event
dispatch.
Self-review of the in-article-find diff turned up four things worth
fixing before someone else trips on them. None are user-visible
today; they're all latent.

*exit_detail_view* cleared the search query but left *input_mode*
alone. Today no code path triggers the bad case — but the day
someone wires a macro or remote-trigger that swaps views, the user
gets stranded in ArticleSearch mode in a view that has no footer.
One line, fixed.

The detail renderer was cloning the entire rendered body into
*article_body_cache* every frame regardless of whether search was
active — about 500KB/s of churn on a 50KB full-text article for a
feature nobody was using. Gate it on "search footer visible" and
the steady state goes to zero allocations.

The doc on *find_article_matches* called the ASCII-only fold a "v1
limitation." It is not a limitation, it is a load-bearing
*invariant*: the highlight render slices the original line at the
byte offsets the search reported, which only works because
*to_ascii_lowercase* is byte-length-preserving. *str::to_lowercase*
— which someone will reach for when they want proper Unicode — is
*not* length-preserving (\`"İ".to_lowercase()\` is two code points)
and would silently invalidate the offset model. Reframe the
comment so a future maintainer can't miss it.

While at it: *build_styled_body* had a dead *'a* lifetime and was
*to_string()*-ing every unmatched line. Borrow from *body* via
*Line::from(&str)* and *Span::raw(&str)*, replace the HashMap
bucketing with a linear walk over already-sorted matches. Tests
added for the invariant, multi-byte offset correctness,
*input_mode* reset, and *build_styled_body* coverage.
@bahdotsh bahdotsh merged commit cc896aa into main May 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant