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 }