ADR 0021: Find & replace — a keyboard-only bar, matches as selections¶
Status¶
Accepted
Context¶
Phase 13 of the "complete normal editor" plan. Two design questions were put to the user before implementing (rather than guessed): case-sensitivity default, and whether the bar should have clickable buttons. Both came back in favor of the smaller-footprint option: case-insensitive by default, no buttons — keyboard only.
QMainWindow::setCentralWidget only takes one widget, so a docked find
bar needs a structural change to main.cpp first.
Decision¶
Structural: a wrapper widget, not a second top-level window¶
main.cpp now builds a plain QWidget with a QVBoxLayout holding
FindBar then EditorViewport (stretch factor 1), and that wrapper —
not the viewport directly — becomes the central widget. FindBar
starts hidden and takes no layout space until opened.
FindBar (gui/src/find_bar.{h,cpp}): input widgets only, no logic¶
One QLineEdit (find) always present when open; a second (replace),
wrapped in its own row widget so it can be shown/hidden as a unit,
appears only in Replace mode (Ctrl+H vs Ctrl+F). No Q_OBJECT, no
custom signals — EditorViewport doesn't declare any either (nothing
in this codebase does yet), so FindBar reaches it through a plain
pointer (EditorViewport::setFindBar, called once from main.cpp
after both are constructed) and calls its public find/replace methods
directly. Key handling (Return/Shift+Return/Ctrl+Return/Escape)
is done via installEventFilter on both line edits rather than
subclassing QLineEdit — smaller diff, and the two fields need
identical Escape handling regardless of which one has focus.
No buttons, per the user's choice above — every action is a keybinding:
Enter in the find field → next match (wraps); Shift+Enter →
previous; Enter in the replace field → replace the current match and
advance; Ctrl+Enter → replace all. Escape from either field hides
the bar and returns focus to the viewport. Opening the bar pre-fills
the find field from the viewport's current single-line selection (not
multi-line — a multi-line starting needle is more surprising than
helpful), matching common editor convention, reusing
EditorViewport::primarySelectionText().
EditorViewport: matches are selections in disguise¶
- Search: plain substring, no regex (matches this project's
standing "minimal" pillar — no coalesced undo, no whole-line copy
fallback, same call here). Case-insensitivity is
QByteArray:: toLower()on fresh copies of both needle and haystack per recompute — ASCII-only, a documented v1 simplification consistent with this codebase's existing byte-level-cursor/no-IME shortcuts (ADR 0006). Because ASCII case-folding never changes a byte's length, the matched range's length always equals the needle's own byte length — no separate bookkeeping needed for that. m_matches/m_currentMatchhold the flat list and current index;recomputeMatches()runs from the existingrefreshCache()choke point unconditionally (a no-op when there's no active query), so matches never go stale after any edit — typing elsewhere in the document, undo/redo, or a find/replace action itself — without a second hook.jumpToMatch(index)is the one place that actually moves the cursor: it setsm_cursors/m_selectionAnchorsto the match's[start, end)range — i.e. the current match just becomes an active selection, reusing Phase 11's model exactly. This is the key reuse of the phase:replaceCurrentMatchdoesn't need its own delete/insert logic at all, it's justinsertText(replacement)over whatever selectionjumpToMatchalready set up — the same path any other typed-over-a-selection edit takes, undo group included.replaceAllMatchesis the one function that doesn't go through the selection path (there's no single "current selection" for N simultaneous matches) — it writes directly throughase_buffer_delete/_insert, highest-offset-first, as one undo group — the same discipline every other multi-offset batch edit in this file already follows (ADR 0012's multi-cursor loops, ADR 0018's undo groups). Leaves the cursor at buffer offset 0 afterward — a documented v1 simplification rather than tracking where the "same" text landed post-replacement.- Rendering: the selection-highlight code from ADR 0019 was
factored into a shared
highlightRange(painter, start, end, firstLine, lastLine, color)helper, now called for both the selection pass and a new match pass. Regular matches reuse the existingselectioncolor; the current match gets a new, strongerfind_matchconfig color (#45403899, the same RGB asselectionat higher alpha — keeping every overlay in this codebase the same tint, only alpha varies, the same reasoning ADR 0019 used) so it's easy to spot among several highlighted matches.
Consequences¶
Verified live against the running ase_gui, driven with xdotool:
- Ctrl+F, typed a needle present three times — pixel-level inspection
(not just eyeballing a screenshot, since ADR 0019 already established
these overlays are subtle at ~40-60% alpha) confirmed all three
occurrences tinted, with the current match measurably darker/stronger
than the other two, matching the expected alpha-blended values for
selection vs. find_match exactly.
- Ctrl+H, replace-one (Enter in the replace field) and replace-all
(Ctrl+Enter) each verified via a save-to-file round trip against
"foo bar foo baz foo", and each undoes as a single Ctrl+Z — full
round trip confirmed for both.
- One real finding during testing, not a bug: after a replace action,
focus deliberately stays in the replace field (so repeated Enter
steps through matches without re-focusing) — and since this codebase
has no window-level shortcut system yet (Ctrl+S etc. are handled
in EditorViewport::keyPressEvent, which only fires when the
viewport itself has focus), other editor shortcuts don't reach the
viewport while a find-bar field is focused. This is consistent with
every other text field in the app having no special-case handling,
not a regression — but it's the reason Escape (or a click back into
the viewport) is needed before e.g. Ctrl+S while the bar is open.
Worth revisiting once Phase 14 adds real chrome/shortcut
infrastructure.
Not built, deliberately: no case-sensitivity toggle, no regex, no buttons — all per the user's explicit choices for this phase. Phase 14 (editor chrome: status bar, dirty tracking, Open/Save As) is next, unrelated to find/replace.