1package picker
2
3import (
4 "strings"
5 "unicode/utf8"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/usecase/fuzzy"
9)
10
11const MaxSearchQueryRunes = 256
12
13type matchField uint8
14
15const (
16 matchName matchField = iota
17 matchDetail
18 matchContext
19)
20
21type fieldMatch struct {
22 field matchField
23 positions []int
24}
25
26type searchMatch struct {
27 fieldMatches []fieldMatch
28}
29
30func (m searchMatch) positions(field matchField) []int {
31 for _, candidate := range m.fieldMatches {
32 if candidate.field == field {
33 return candidate.positions
34 }
35 }
36 return nil
37}
38
39func (m *Model) EnterSearch() {
40 if m == nil || m.searchActive {
41 return
42 }
43 m.searchActive = true
44 m.query.SetValue("")
45 m.refreshSearch(false)
46}
47
48func (m *Model) ExitSearch() {
49 if m == nil {
50 return
51 }
52 m.searchActive = false
53 m.query.SetValue("")
54 m.searchMatches = nil
55 m.matchRows = nil
56 m.refreshView()
57 m.normalizeCursor()
58}
59
60func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
61
62func (m *Model) Query() string {
63 if m == nil {
64 return ""
65 }
66 return m.query.Value()
67}
68
69func (m *Model) SearchTitle(width ...int) string {
70 if m == nil || !m.searchActive {
71 return ""
72 }
73 full := " Search sessions & tabs: " + m.query.Value() + "_ "
74 if len(width) == 0 || textCellWidth(full) <= width[0] {
75 return full
76 }
77 const (
78 prefix = " / "
79 suffix = "_ "
80 )
81 fixedWidth := textCellWidth(prefix) + textCellWidth(suffix)
82 if width[0] < fixedWidth {
83 return tailCells("_", width[0])
84 }
85 available := width[0] - fixedWidth
86 return prefix + tailCells(m.query.Value(), available) + suffix
87}
88
89func tailCells(text string, width int) string {
90 runes := []rune(text)
91 used := 0
92 start := len(runes)
93 for start > 0 {
94 cellWidth := renderer.RuneWidth(runes[start-1])
95 if used+cellWidth > width {
96 break
97 }
98 used += cellWidth
99 start--
100 }
101 return string(runes[start:])
102}
103
104func textCellWidth(text string) int {
105 width := 0
106 for _, r := range text {
107 width += renderer.RuneWidth(r)
108 }
109 return width
110}
111
112func (m *Model) InsertSearch(r rune) {
113 if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes {
114 return
115 }
116 m.query.Insert(r)
117 m.refreshSearch(true)
118}
119
120func (m *Model) BackspaceSearch() {
121 if m == nil || !m.searchActive || m.query.Value() == "" {
122 return
123 }
124 m.query.Backspace()
125 m.refreshSearch(true)
126}
127
128func (m *Model) ClearSearch() {
129 if m == nil || !m.searchActive || m.query.Value() == "" {
130 return
131 }
132 m.query.SetValue("")
133 m.refreshSearch(true)
134}
135
136func (m *Model) MatchCount() int {
137 if m == nil || !m.searchActive {
138 return 0
139 }
140 return len(m.matchRows)
141}
142
143// ReplaceFrom applies a fresh canonical row snapshot while retaining the
144// attachment-local search editor and exact cursor when that key still exists.
145// Callers serialize this mutation with their picker ownership lock.
146func (m *Model) ReplaceFrom(next *Model) {
147 if m == nil || next == nil {
148 return
149 }
150 key, hadKey := m.cursorKey()
151 searchActive, query := m.searchActive, m.query.Value()
152 m.intent = next.intent
153 m.sort = next.sort
154 m.lines = append(m.lines[:0], next.lines...)
155 m.searchActive = searchActive
156 m.query.SetValue(query)
157 m.rebuild(key, hadKey, next.selected)
158}
159
160func (m *Model) rowMatches(idx int) bool {
161 if m == nil || !m.searchActive || m.query.Value() == "" {
162 return true
163 }
164 _, ok := m.searchMatches[idx]
165 return ok
166}
167
168// refreshSearch recomputes the rows the active query shows and re-places the
169// cursor: the editor never leaves it on a row the query hides. Matches keep
170// the list's tree order, so typing narrows the list without reordering it;
171// when the query changed (selectFirst) the cursor rests on the first match.
172func (m *Model) refreshSearch(selectFirst bool) {
173 m.searchMatches = make(map[int]searchMatch)
174 m.matchRows = make([]int, 0, len(m.rows))
175 query := strings.ToLower(m.query.Value())
176 if query == "" {
177 for idx, row := range m.rows {
178 if row.focusable() {
179 m.matchRows = append(m.matchRows, idx)
180 }
181 }
182 m.refreshView()
183 if selectFirst {
184 m.normalizeCursor()
185 }
186 return
187 }
188
189 needleRunes := []rune(query)
190 for idx, row := range m.rows {
191 if !row.focusable() {
192 continue
193 }
194 matched, ok := matchRow(row, query, needleRunes)
195 if !ok {
196 continue
197 }
198 m.searchMatches[idx] = matched
199 m.matchRows = append(m.matchRows, idx)
200 }
201 m.refreshView()
202 if selectFirst {
203 m.selected = -1
204 if len(m.matchRows) > 0 {
205 m.selected = m.matchRows[0]
206 }
207 }
208}
209
210// normalizeCursor places the cursor on a drawn row. It keeps the current row
211// when it qualifies; otherwise a query takes its first match, a fold hands the
212// cursor to the collapsed section, and anything else takes the nearest
213// eligible row. It clears the cursor when nothing qualifies.
214func (m *Model) normalizeCursor() {
215 if m == nil || m.eligible(m.selected) {
216 return
217 }
218 if m.searchRestricted() {
219 // The query shows nothing the cursor may rest on.
220 m.selected = -1
221 if len(m.matchRows) > 0 {
222 m.selected = m.matchRows[0]
223 }
224 return
225 }
226 if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] {
227 // A row a collapsed section hides leaves the cursor on that section.
228 if section := m.sectionOf(m.selected); m.eligible(section) {
229 m.selected = section
230 return
231 }
232 }
233 hint := m.selected
234 m.selected = -1
235 m.SelectNearestRow(hint)
236 if m.selected < 0 {
237 m.selected = m.firstEligible()
238 }
239}
240
241func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
242 fields := []struct {
243 kind matchField
244 text string
245 }{
246 {matchName, row.foldedLabel},
247 {matchDetail, row.foldedDetail},
248 {matchContext, row.foldedContext},
249 }
250
251 var result searchMatch
252 for _, field := range fields {
253 match, ok := scoreField(field.kind, field.text, query, needleRunes)
254 if !ok {
255 continue
256 }
257 result.fieldMatches = append(result.fieldMatches, match)
258 }
259 return result, len(result.fieldMatches) != 0
260}
261
262func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
263 matched, ok := fuzzy.Match(text, query, needle)
264 if !ok {
265 return fieldMatch{}, false
266 }
267 return fieldMatch{field: field, positions: matched.Positions}, true
268}