1package visualsearch
2
3import (
4 "strconv"
5 "strings"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/ui"
11)
12
13type Model struct {
14 snapshot scopy.Snapshot
15 input ui.TextInput
16 matches []scopy.SearchMatch
17 // matchedQuery is the trimmed query matches was computed for.
18 matchedQuery string
19 selected int
20 scroll int
21}
22
23func New(snapshot scopy.Snapshot) *Model {
24 m := &Model{snapshot: snapshot, selected: -1}
25 m.refresh()
26 return m
27}
28
29func (m *Model) Clone() *Model {
30 if m == nil {
31 return nil
32 }
33 clone := *m
34 clone.input.SetValue(m.input.Value())
35 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
36 return &clone
37}
38
39func (m *Model) Snapshot() scopy.Snapshot {
40 if m == nil {
41 return scopy.NewSnapshotFromRows(nil, 0, 0)
42 }
43 return m.snapshot
44}
45
46func (m *Model) Insert(r rune) {
47 if m == nil {
48 return
49 }
50 m.input.Insert(r)
51 m.selected = 0
52 m.scroll = 0
53 m.refresh()
54}
55
56func (m *Model) Backspace() {
57 if m == nil || m.input.Value() == "" {
58 return
59 }
60 m.input.Backspace()
61 m.selected = 0
62 m.scroll = 0
63 m.refresh()
64}
65
66func (m *Model) Query() string {
67 if m == nil {
68 return ""
69 }
70 return m.input.Value()
71}
72
73// searchInputPrefix leads the query line on row 0 of the rendered frame.
74const searchInputPrefix = "/"
75
76// Caret is the query caret column on row 0 of a Render frame of size inner
77// (see ui.InputCaret).
78func (m *Model) Caret(inner domain.Size) (int, bool) {
79 return ui.InputCaret(inner, searchInputPrefix, m.Query())
80}
81
82func (m *Model) Up() {
83 if m != nil && m.selected > 0 {
84 m.selected--
85 m.clamp()
86 m.ensureVisible(defaultVisibleRows)
87 }
88}
89
90func (m *Model) Down() {
91 if m != nil && m.selected+1 < len(m.matches) {
92 m.selected++
93 m.clamp()
94 m.ensureVisible(defaultVisibleRows)
95 }
96}
97
98func (m *Model) Selected() (scopy.SearchMatch, bool) {
99 idx := m.SelectedIndex()
100 if idx < 0 {
101 return scopy.SearchMatch{}, false
102 }
103 return m.matches[idx], true
104}
105
106func (m *Model) SelectedIndex() int {
107 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
108 return -1
109 }
110 return m.selected
111}
112
113func (m *Model) Matches() []scopy.SearchMatch {
114 if m == nil {
115 return nil
116 }
117 return append([]scopy.SearchMatch(nil), m.matches...)
118}
119
120func (m *Model) refresh() {
121 doc := scopy.NewDocument(m.snapshot, "")
122 query := strings.TrimSpace(m.input.Value())
123 // Typing extends the query, so only rows that already matched can match.
124 if m.matchedQuery != "" && strings.HasPrefix(query, m.matchedQuery) {
125 m.matches = scopy.RefineMatches(doc, m.matches, query)
126 } else {
127 m.matches = scopy.FindMatches(doc, query)
128 }
129 m.matchedQuery = query
130 m.clamp()
131 m.ensureVisible(defaultVisibleRows)
132}
133
134func (m *Model) clamp() {
135 if len(m.matches) == 0 {
136 m.selected = -1
137 m.scroll = 0
138 return
139 }
140 if m.selected < 0 {
141 m.selected = 0
142 }
143 if m.selected >= len(m.matches) {
144 m.selected = len(m.matches) - 1
145 }
146 if m.scroll < 0 {
147 m.scroll = 0
148 }
149 if m.scroll > m.selected {
150 m.scroll = m.selected
151 }
152}
153
154const defaultVisibleRows = 10
155
156// RenderStyles contains the base chrome surface and active selection role.
157type RenderStyles struct {
158 Base renderer.Style
159 Selection renderer.Style
160}
161
162func (m *Model) ensureVisible(visible int) {
163 if m == nil || visible <= 0 || len(m.matches) == 0 {
164 return
165 }
166 if m.selected < m.scroll {
167 m.scroll = m.selected
168 }
169 if m.selected >= m.scroll+visible {
170 m.scroll = m.selected - visible + 1
171 }
172}
173
174func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
175 base := renderer.DefaultStyle()
176 selection := base
177 selection.Inverse = true
178 if len(selectedStyle) > 0 {
179 selection = selectedStyle[0]
180 }
181 return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
182}
183
184// RenderStyled renders with explicit chrome roles captured by the daemon.
185func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
186 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
187 if frame.Width == 0 || frame.Height == 0 {
188 return frame
189 }
190 base, selection := styles.Base, styles.Selection
191 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
192 ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
193 visible := frame.Height - 1
194 if visible <= 0 || len(m.matches) == 0 {
195 return frame
196 }
197 scroll := m.scroll
198 if m.selected < scroll {
199 scroll = m.selected
200 }
201 if m.selected >= scroll+visible {
202 scroll = m.selected - visible + 1
203 }
204 for y := range visible {
205 idx := scroll + y
206 if idx >= len(m.matches) {
207 break
208 }
209 match := m.matches[idx]
210 style := base
211 if idx == m.selected {
212 style = selection
213 }
214 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
215 label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text
216 ui.DrawText(frame, 0, y+1, frame.Width, label, style)
217 }
218 return frame
219}