▍ humdrum codex / glint v1.0.2
license AGPL-3.0

feat: in-document find with live highlight (TASK-007)

7c8bb9ddf45ad58e79852607f3a437a239c13592
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 13:47

parent 56e8e447

feat: in-document find with live highlight (TASK-007)

Ctrl+G opens a find bar over the editor buffer (Ctrl+F is the picker).
Case-insensitive, non-overlapping substring matching, recomputed live as
the query is typed. All matches are highlighted; the active match is
styled more strongly and the cursor parks on it with the viewport
scrolling to keep it visible. Enter / Down cycle to the next match,
Shift+Tab / Up to the previous, both wrapping; Esc closes and clears.

Shift+Enter/N (suggested in the task) aren't usable — terminals can't
distinguish Shift+Enter and n/N collide with live query entry — so prev
is Shift+Tab/Up and next is Enter/Down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

8 files changed

README.md +1 −0
@@ -56,6 +56,7 @@ | `Ctrl+Z` / `Ctrl+Y` | undo / redo |
 | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
 | `Ctrl+P` | toggle the Glamour read preview |
 | `Ctrl+F` | fuzzy file picker (with live preview) |
+| `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
 | `Ctrl+D` | today's daily note |
 | `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
 | `Ctrl+B` | new note in the inbox |
- → Find-in-document.md +11 −5
@@ -1,10 +1,10 @@
 ---
 id: TASK-007
 title: Find in document
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 20:46'
 labels:
   - feature
   - release-1
@@ -21,7 +21,13 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 A keybind opens a find prompt; typing filters/highlights matches
-- [ ] #2 Next/prev cycle through matches, wrapping; view scrolls to the match
-- [ ] #3 Esc closes the find bar; case-insensitive matching
+- [x] #1 A keybind opens a find prompt; typing filters/highlights matches
+- [x] #2 Next/prev cycle through matches, wrapping; view scrolls to the match
+- [x] #3 Esc closes the find bar; case-insensitive matching
 <!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented. Editor: internal/editor/find.go (case-insensitive non-overlapping substring matches, active-match cursor parking + scroll-to-match, all-matches highlight in View; Highlight bg for matches, SelBg for the active one). App: ModeFind + find bar (textinput), opened with Ctrl+G (Ctrl+F is the picker). Enter/Down = next, Shift+Tab/Up = prev (wrapping); Esc closes and clears. Note: description suggested Shift+Enter/N for prev and n for next, but terminals can't distinguish Shift+Enter and n/N collide with live query typing, so prev is Shift+Tab/Up and next is Enter/Down. 16 new tests (12 editor, 4 app); all green, go vet + golangci-lint clean.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +72 −1
@@ -30,6 +30,7 @@ 	ModeEditor Mode = iota
 	ModePicker
 	ModePreview
 	ModeSaveAs
+	ModeFind
 )
 
 // pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -60,6 +61,7 @@ 	editor     *editor.Editor
 	preview    *preview.Model
 	picker     *picker.Model
 	saveInput  textinput.Model // one-line "save as" prompt for unnamed buffers
+	findInput  textinput.Model // one-line in-document find prompt (TASK-007)
 	path       string
 	pickerRoot string // directory the current picker is browsing
 	saveDir    string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -79,12 +81,16 @@ 	ed.SetTheme(th)
 	ti := textinput.New()
 	ti.Prompt = "save as › "
 	ti.Placeholder = "name…"
+	fi := textinput.New()
+	fi.Prompt = "find › "
+	fi.Placeholder = "text…"
 	a := &App{
 		mode:      ModeEditor,
 		cfg:       cfg,
 		theme:     th,
 		editor:    ed,
 		saveInput: ti,
+		findInput: fi,
 	}
 	a.preview = preview.New(a.glamourStyle())
 	a.preview.SetColors(previewColors(th))
@@ -251,7 +257,12 @@ 	case tea.KeyCtrlX:
 		return a.copySelection(true)
 	case tea.KeyCtrlV:
 		return a.paste()
+	case tea.KeyCtrlG:
+		return a.openFind()
 	case tea.KeyEsc:
+		if a.mode == ModeFind {
+			a.editor.ClearFind()
+		}
 		a.mode = ModeEditor
 		return a, nil
 	}
@@ -266,6 +277,8 @@ 		}
 		var cmd tea.Cmd
 		a.saveInput, cmd = a.saveInput.Update(msg)
 		return a, cmd
+	case ModeFind:
+		return a.handleFindKey(msg)
 	case ModePreview:
 		return a, a.preview.Update(msg)
 	case ModePicker:
@@ -334,6 +347,51 @@ 	a.status = "Saved " + p
 	return a, nil
 }
 
+// openFind opens the in-document find bar over the current editor buffer.
+// Ctrl+F is already the file picker, so find is Ctrl+G.
+func (a *App) openFind() (tea.Model, tea.Cmd) {
+	if a.mode != ModeEditor {
+		return a, nil
+	}
+	a.findInput.SetValue("")
+	a.findInput.Focus()
+	a.editor.ClearFind()
+	a.mode = ModeFind
+	a.status = "Find — type to search, Enter/↓ next, Shift+Tab/↑ prev, Esc to close"
+	return a, nil
+}
+
+// handleFindKey drives the find bar: Enter/Down cycle to the next match,
+// Shift+Tab/Up to the previous, and any other key edits the query live.
+func (a *App) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	switch msg.Type {
+	case tea.KeyEnter, tea.KeyDown:
+		a.editor.FindNext()
+		a.status = a.findStatus()
+		return a, nil
+	case tea.KeyShiftTab, tea.KeyUp:
+		a.editor.FindPrev()
+		a.status = a.findStatus()
+		return a, nil
+	}
+	var cmd tea.Cmd
+	a.findInput, cmd = a.findInput.Update(msg)
+	a.editor.SetFindQuery(a.findInput.Value())
+	a.status = a.findStatus()
+	return a, cmd
+}
+
+// findStatus summarizes the current match count for the status bar.
+func (a *App) findStatus() string {
+	if a.findInput.Value() == "" {
+		return "Find — type to search"
+	}
+	if a.editor.FindCount() == 0 {
+		return "No matches"
+	}
+	return fmt.Sprintf("%d matches", a.editor.FindCount())
+}
+
 // newFile is the Ctrl+N / Ctrl+I handler: start a new note in dir. From the
 // picker with a typed query it creates dir/<query>.md; otherwise it opens a
 // blank buffer whose save-as targets dir (confirming discard if the editor is
@@ -608,10 +666,23 @@ 	} else {
 		body = a.editor.View() // editor stays visible beneath the save-as prompt
 	}
 	bottom := a.statusBar()
-	if a.mode == ModeSaveAs {
+	switch a.mode {
+	case ModeSaveAs:
 		bottom = a.saveBar()
+	case ModeFind:
+		bottom = a.findBar()
 	}
 	return a.paintCanvas(body) + bottom
+}
+
+// findBar renders the find prompt as a themed full-width bottom bar, with the
+// match count trailing.
+func (a *App) findBar() string {
+	bar := lipgloss.NewStyle().
+		Foreground(a.theme.StatusFg).
+		Background(a.theme.StatusBg).
+		Width(maxInt(a.width, 1))
+	return bar.Render(" " + a.findInput.View() + "  " + a.findStatus() + " ")
 }
 
 // saveBar renders the save-as prompt as a themed full-width bottom bar.
internal/app/app_test.go +50 −0
@@ -639,3 +639,53 @@ 	if a.editor.Lines[0] != "hello" {
 		t.Errorf("Ctrl+Y should redo, got %q", a.editor.Lines[0])
 	}
 }
+
+func TestCtrlGOpensFind(t *testing.T) {
+	a := newApp()
+	a.editor.SetContent([]byte("alpha beta alpha"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+	if a.mode != ModeFind {
+		t.Fatalf("mode = %d, want ModeFind", a.mode)
+	}
+}
+
+func TestFindQueryHighlightsAndJumps(t *testing.T) {
+	a := newApp()
+	a.editor.SetContent([]byte("alpha\nbeta\nalpha"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+	for _, r := range "alpha" {
+		a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+	if a.editor.FindCount() != 2 {
+		t.Fatalf("FindCount = %d, want 2", a.editor.FindCount())
+	}
+}
+
+func TestFindEnterCyclesNext(t *testing.T) {
+	a := newApp()
+	a.editor.SetContent([]byte("x\nx\nx"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+	row, _, _, _ := a.editor.ActiveMatch()
+	if row != 0 {
+		t.Fatalf("first active row = %d, want 0", row)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	if row, _, _, _ := a.editor.ActiveMatch(); row != 1 {
+		t.Fatalf("after Enter, active row = %d, want 1", row)
+	}
+}
+
+func TestFindEscClosesAndClears(t *testing.T) {
+	a := newApp()
+	a.editor.SetContent([]byte("alpha alpha"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+	a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if a.mode != ModeEditor {
+		t.Fatalf("mode = %d, want ModeEditor after Esc", a.mode)
+	}
+	if a.editor.FindCount() != 0 {
+		t.Errorf("Esc should clear find, got %d", a.editor.FindCount())
+	}
+}
internal/editor/editor.go +14 −0
@@ -30,6 +30,10 @@
 	undo     []snapshot // edit checkpoints, oldest first (TASK-006)
 	redo     []snapshot // undone checkpoints awaiting redo
 	lastKind editKind   // kind of the last recorded group, for coalescing
+
+	find       []match // in-document find matches, document order (TASK-007)
+	findActive int     // index of the active match in find, -1 = none
+	findQuery  string  // current find query
 }
 
 // New returns an empty editor with one blank line and the default theme.
@@ -57,6 +61,7 @@ 	e.Scroll = 0
 	e.goalCol = 0
 	e.Dirty = false
 	e.resetHistory()
+	e.ClearFind()
 }
 
 // Bytes serializes the buffer with \n line separators.
@@ -587,6 +592,8 @@ // the cursor's visual row. Output is e.Width columns wide; the app adds margins.
 func (e *Editor) View() string {
 	cursorStyle := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.Pointer)
 	selStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg)
+	findStyle := lipgloss.NewStyle().Foreground(e.theme.Text).Background(e.theme.Highlight)
+	findActiveStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg)
 	rows := e.buildVisual()
 	ci := cursorVIndex(rows, e.Cursor)
 	var b strings.Builder
@@ -598,6 +605,13 @@ 	for r := e.Scroll; r < end; r++ {
 		spans := rows[r].spans
 		if a, bb, ok := e.selectionForRow(rows[r]); ok {
 			spans = overlaySelection(spans, a, bb, selStyle)
+		}
+		for _, m := range e.matchRangesForRow(rows[r]) {
+			st := findStyle
+			if m.active {
+				st = findActiveStyle
+			}
+			spans = overlaySelection(spans, m.a, m.b, st)
 		}
 		if r == ci {
 			b.WriteString(renderSpansCursor(spans, e.Cursor.Col-rows[r].start, cursorStyle))
internal/editor/find.go +138 −0
@@ -0,0 +1,138 @@
+package editor
+
+import "strings"
+
+// In-document find (TASK-007): a case-insensitive substring search over the
+// buffer. Matches are kept in document order; one is "active" (the cursor parks
+// on it) and View highlights all of them, the active one more strongly. The app
+// drives this from a find bar — live SetFindQuery as the query is typed, then
+// FindNext/FindPrev to cycle.
+
+// match is a found range within a single logical line, in rune columns.
+type match struct {
+	row      int
+	startCol int
+	endCol   int
+}
+
+// SetFindQuery recomputes matches for q (case-insensitive) and activates the
+// first match at or after the cursor, wrapping to the first otherwise. It moves
+// the cursor onto that match and scrolls it into view. Returns the match count.
+func (e *Editor) SetFindQuery(q string) int {
+	e.findQuery = q
+	e.find = nil
+	e.findActive = -1
+	if q == "" {
+		return 0
+	}
+	qr := []rune(strings.ToLower(q))
+	for row, line := range e.Lines {
+		lr := []rune(strings.ToLower(line))
+		for i := 0; i+len(qr) <= len(lr); {
+			if runesEqual(lr[i:i+len(qr)], qr) {
+				e.find = append(e.find, match{row: row, startCol: i, endCol: i + len(qr)})
+				i += len(qr) // non-overlapping
+			} else {
+				i++
+			}
+		}
+	}
+	if len(e.find) == 0 {
+		return 0
+	}
+	e.findActive = 0
+	for idx, m := range e.find {
+		if m.row > e.Cursor.Row || (m.row == e.Cursor.Row && m.startCol >= e.Cursor.Col) {
+			e.findActive = idx
+			break
+		}
+	}
+	e.moveToActive()
+	return len(e.find)
+}
+
+// FindNext activates the next match, wrapping around.
+func (e *Editor) FindNext() {
+	if len(e.find) == 0 {
+		return
+	}
+	e.findActive = (e.findActive + 1) % len(e.find)
+	e.moveToActive()
+}
+
+// FindPrev activates the previous match, wrapping around.
+func (e *Editor) FindPrev() {
+	if len(e.find) == 0 {
+		return
+	}
+	e.findActive = (e.findActive - 1 + len(e.find)) % len(e.find)
+	e.moveToActive()
+}
+
+// ClearFind drops all find state (called on Esc and when the buffer is replaced).
+func (e *Editor) ClearFind() {
+	e.find = nil
+	e.findActive = -1
+	e.findQuery = ""
+}
+
+// FindCount returns the number of current matches.
+func (e *Editor) FindCount() int { return len(e.find) }
+
+// ActiveMatch reports the active match's location, or ok=false if none.
+func (e *Editor) ActiveMatch() (row, startCol, endCol int, ok bool) {
+	if e.findActive < 0 || e.findActive >= len(e.find) {
+		return 0, 0, 0, false
+	}
+	m := e.find[e.findActive]
+	return m.row, m.startCol, m.endCol, true
+}
+
+// moveToActive parks the cursor at the active match start and scrolls to it.
+func (e *Editor) moveToActive() {
+	if e.findActive < 0 || e.findActive >= len(e.find) {
+		return
+	}
+	m := e.find[e.findActive]
+	e.Cursor = Position{Row: m.row, Col: m.startCol}
+	e.setGoal()
+	e.followCursor()
+}
+
+// rowMatch is a find match clipped to one visual row, in row-local rune columns.
+type rowMatch struct {
+	a, b   int
+	active bool
+}
+
+// matchRangesForRow returns the find matches intersecting visual row vr.
+func (e *Editor) matchRangesForRow(vr vrow) []rowMatch {
+	if len(e.find) == 0 {
+		return nil
+	}
+	rowStart, rowEnd := vr.start, vr.start+vr.runes
+	var out []rowMatch
+	for i, m := range e.find {
+		if m.row != vr.logRow {
+			continue
+		}
+		lo := max(m.startCol, rowStart)
+		hi := min(m.endCol, rowEnd)
+		if lo < hi {
+			out = append(out, rowMatch{a: lo - rowStart, b: hi - rowStart, active: i == e.findActive})
+		}
+	}
+	return out
+}
+
+func runesEqual(a, b []rune) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
internal/editor/find_test.go +131 −0
@@ -0,0 +1,131 @@
+package editor
+
+import "testing"
+
+func TestSetFindQueryFindsAllMatches(t *testing.T) {
+	e := newEditorWith("foo bar foo", "baz foo")
+	if n := e.SetFindQuery("foo"); n != 3 {
+		t.Fatalf("want 3 matches, got %d", n)
+	}
+	if e.FindCount() != 3 {
+		t.Errorf("FindCount = %d, want 3", e.FindCount())
+	}
+}
+
+func TestFindIsCaseInsensitive(t *testing.T) {
+	e := newEditorWith("Foo foo FOO")
+	if n := e.SetFindQuery("foo"); n != 3 {
+		t.Fatalf("case-insensitive find: want 3, got %d", n)
+	}
+}
+
+func TestFindMatchesAreNonOverlapping(t *testing.T) {
+	e := newEditorWith("aaaa")
+	if n := e.SetFindQuery("aa"); n != 2 {
+		t.Fatalf("non-overlapping: want 2, got %d", n)
+	}
+}
+
+func TestSetFindQueryActivatesFirstMatchFromCursor(t *testing.T) {
+	e := newEditorWith("foo", "foo", "foo")
+	e.Cursor = Position{Row: 1, Col: 0}
+	e.SetFindQuery("foo")
+	row, _, _, ok := e.ActiveMatch()
+	if !ok || row != 1 {
+		t.Fatalf("active match row = %d ok=%v, want row 1", row, ok)
+	}
+}
+
+func TestSetFindQueryMovesCursorToActiveMatch(t *testing.T) {
+	e := newEditorWith("alpha", "beta target", "gamma")
+	e.SetFindQuery("target")
+	if e.Cursor.Row != 1 || e.Cursor.Col != 5 {
+		t.Fatalf("cursor = %+v, want {1,5}", e.Cursor)
+	}
+}
+
+func TestFindNextWraps(t *testing.T) {
+	e := newEditorWith("x", "x", "x")
+	e.SetFindQuery("x") // active 0
+	e.FindNext()        // 1
+	e.FindNext()        // 2
+	if r, _, _, _ := e.ActiveMatch(); r != 2 {
+		t.Fatalf("after 2 next, active row = %d, want 2", r)
+	}
+	e.FindNext() // wraps to 0
+	if r, _, _, _ := e.ActiveMatch(); r != 0 {
+		t.Fatalf("FindNext should wrap to row 0, got %d", r)
+	}
+}
+
+func TestFindPrevWraps(t *testing.T) {
+	e := newEditorWith("x", "x", "x")
+	e.Cursor = Position{Row: 0, Col: 0}
+	e.SetFindQuery("x") // active 0
+	e.FindPrev()        // wraps to last
+	if r, _, _, _ := e.ActiveMatch(); r != 2 {
+		t.Fatalf("FindPrev should wrap to row 2, got %d", r)
+	}
+}
+
+func TestFindNextScrollsToMatch(t *testing.T) {
+	lines := make([]string, 40)
+	for i := range lines {
+		lines[i] = "line"
+	}
+	lines[30] = "needle"
+	e := newEditorWith(lines...)
+	e.SetSize(80, 5) // only 5 visible rows
+	e.SetFindQuery("needle")
+	if e.Scroll == 0 {
+		t.Fatalf("view should scroll so the match is visible, Scroll=%d", e.Scroll)
+	}
+	ci := cursorVIndex(e.buildVisual(), e.Cursor)
+	if ci < e.Scroll || ci >= e.Scroll+e.Height {
+		t.Fatalf("active match (vrow %d) not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height)
+	}
+}
+
+func TestEmptyQueryClearsMatches(t *testing.T) {
+	e := newEditorWith("foo foo")
+	e.SetFindQuery("foo")
+	if e.SetFindQuery(""); e.FindCount() != 0 {
+		t.Fatalf("empty query should clear matches, got %d", e.FindCount())
+	}
+	if _, _, _, ok := e.ActiveMatch(); ok {
+		t.Error("empty query should clear the active match")
+	}
+}
+
+func TestClearFindResetsState(t *testing.T) {
+	e := newEditorWith("foo foo")
+	e.SetFindQuery("foo")
+	e.ClearFind()
+	if e.FindCount() != 0 {
+		t.Fatalf("ClearFind should drop matches, got %d", e.FindCount())
+	}
+	if _, _, _, ok := e.ActiveMatch(); ok {
+		t.Error("ClearFind should clear the active match")
+	}
+}
+
+func TestFindNoMatches(t *testing.T) {
+	e := newEditorWith("hello world")
+	if n := e.SetFindQuery("zzz"); n != 0 {
+		t.Fatalf("want 0 matches, got %d", n)
+	}
+	if _, _, _, ok := e.ActiveMatch(); ok {
+		t.Error("no matches should mean no active match")
+	}
+	e.FindNext() // must not panic
+	e.FindPrev()
+}
+
+func TestSetContentClearsFind(t *testing.T) {
+	e := newEditorWith("foo foo")
+	e.SetFindQuery("foo")
+	e.SetContent([]byte("bar"))
+	if e.FindCount() != 0 {
+		t.Fatalf("SetContent should clear find state, got %d", e.FindCount())
+	}
+}
main.go +1 −0
@@ -48,6 +48,7 @@ EDITOR KEYS
   Ctrl+S                save (an unnamed buffer prompts for a name)
   Ctrl+P                toggle the read preview
   Ctrl+F                fuzzy file picker
+  Ctrl+G                find in document (Enter/down next, Shift+Tab/up prev)
   Ctrl+D                today's daily note
   Ctrl+N                new note in the current directory
   Ctrl+B                new note in the inbox