package visualsearch import ( "strconv" "strings" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" scopy "github.com/bnema/vev/internal/usecase/copy" "github.com/bnema/vev/internal/usecase/ui" ) type Model struct { snapshot scopy.Snapshot input ui.TextInput matches []scopy.SearchMatch // matchedQuery is the trimmed query matches was computed for. matchedQuery string selected int scroll int } func New(snapshot scopy.Snapshot) *Model { m := &Model{snapshot: snapshot, selected: -1} m.refresh() return m } func (m *Model) Clone() *Model { if m == nil { return nil } clone := *m clone.input.SetValue(m.input.Value()) clone.matches = append([]scopy.SearchMatch(nil), m.matches...) return &clone } func (m *Model) Snapshot() scopy.Snapshot { if m == nil { return scopy.NewSnapshotFromRows(nil, 0, 0) } return m.snapshot } func (m *Model) Insert(r rune) { if m == nil { return } m.input.Insert(r) m.selected = 0 m.scroll = 0 m.refresh() } func (m *Model) Backspace() { if m == nil || m.input.Value() == "" { return } m.input.Backspace() m.selected = 0 m.scroll = 0 m.refresh() } func (m *Model) Query() string { if m == nil { return "" } return m.input.Value() } // searchInputPrefix leads the query line on row 0 of the rendered frame. const searchInputPrefix = "/" // Caret is the query caret column on row 0 of a Render frame of size inner // (see ui.InputCaret). func (m *Model) Caret(inner domain.Size) (int, bool) { return ui.InputCaret(inner, searchInputPrefix, m.Query()) } func (m *Model) Up() { if m != nil && m.selected > 0 { m.selected-- m.clamp() m.ensureVisible(defaultVisibleRows) } } func (m *Model) Down() { if m != nil && m.selected+1 < len(m.matches) { m.selected++ m.clamp() m.ensureVisible(defaultVisibleRows) } } func (m *Model) Selected() (scopy.SearchMatch, bool) { idx := m.SelectedIndex() if idx < 0 { return scopy.SearchMatch{}, false } return m.matches[idx], true } func (m *Model) SelectedIndex() int { if m == nil || m.selected < 0 || m.selected >= len(m.matches) { return -1 } return m.selected } func (m *Model) Matches() []scopy.SearchMatch { if m == nil { return nil } return append([]scopy.SearchMatch(nil), m.matches...) } func (m *Model) refresh() { doc := scopy.NewDocument(m.snapshot, "") query := strings.TrimSpace(m.input.Value()) // Typing extends the query, so only rows that already matched can match. if m.matchedQuery != "" && strings.HasPrefix(query, m.matchedQuery) { m.matches = scopy.RefineMatches(doc, m.matches, query) } else { m.matches = scopy.FindMatches(doc, query) } m.matchedQuery = query m.clamp() m.ensureVisible(defaultVisibleRows) } func (m *Model) clamp() { if len(m.matches) == 0 { m.selected = -1 m.scroll = 0 return } if m.selected < 0 { m.selected = 0 } if m.selected >= len(m.matches) { m.selected = len(m.matches) - 1 } if m.scroll < 0 { m.scroll = 0 } if m.scroll > m.selected { m.scroll = m.selected } } const defaultVisibleRows = 10 // RenderStyles contains the base chrome surface and active selection role. type RenderStyles struct { Base renderer.Style Selection renderer.Style } func (m *Model) ensureVisible(visible int) { if m == nil || visible <= 0 || len(m.matches) == 0 { return } if m.selected < m.scroll { m.scroll = m.selected } if m.selected >= m.scroll+visible { m.scroll = m.selected - visible + 1 } } func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { base := renderer.DefaultStyle() selection := base selection.Inverse = true if len(selectedStyle) > 0 { selection = selectedStyle[0] } return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection}) } // RenderStyled renders with explicit chrome roles captured by the daemon. func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame { frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) if frame.Width == 0 || frame.Height == 0 { return frame } base, selection := styles.Base, styles.Selection ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base) visible := frame.Height - 1 if visible <= 0 || len(m.matches) == 0 { return frame } scroll := m.scroll if m.selected < scroll { scroll = m.selected } if m.selected >= scroll+visible { scroll = m.selected - visible + 1 } for y := range visible { idx := scroll + y if idx >= len(m.matches) { break } match := m.matches[idx] style := base if idx == m.selected { style = selection } ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text ui.DrawText(frame, 0, y+1, frame.Width, label, style) } return frame }