1package picker
2
3import (
4 "slices"
5 "strings"
6 "unicode/utf8"
7
8 renderer "github.com/bnema/vev-vt"
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 rank int
25 span int
26 first int
27}
28
29type searchMatch struct {
30 fieldMatches []fieldMatch
31 best fieldMatch
32}
33
34func (m searchMatch) positions(field matchField) []int {
35 for _, candidate := range m.fieldMatches {
36 if candidate.field == field {
37 return candidate.positions
38 }
39 }
40 return nil
41}
42
43func (m *Model) EnterSearch() {
44 if m == nil || m.searchActive {
45 return
46 }
47 m.searchActive = true
48 m.query.SetValue("")
49 m.refreshSearch(false)
50}
51
52func (m *Model) ExitSearch() {
53 if m == nil {
54 return
55 }
56 m.searchActive = false
57 m.query.SetValue("")
58 m.searchMatches = nil
59 m.matchRows = nil
60}
61
62func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
63
64func (m *Model) Query() string {
65 if m == nil {
66 return ""
67 }
68 return m.query.Value()
69}
70
71func (m *Model) SearchTitle(width ...int) string {
72 if m == nil || !m.searchActive {
73 return ""
74 }
75 full := " Search sessions & tabs: " + m.query.Value() + "_ "
76 if len(width) == 0 || textCellWidth(full) <= width[0] {
77 return full
78 }
79 const (
80 prefix = " / "
81 suffix = "_ "
82 )
83 fixedWidth := textCellWidth(prefix) + textCellWidth(suffix)
84 if width[0] < fixedWidth {
85 return tailCells("_", width[0])
86 }
87 available := width[0] - fixedWidth
88 return prefix + tailCells(m.query.Value(), available) + suffix
89}
90
91func tailCells(text string, width int) string {
92 runes := []rune(text)
93 used := 0
94 start := len(runes)
95 for start > 0 {
96 cellWidth := renderer.RuneWidth(runes[start-1])
97 if used+cellWidth > width {
98 break
99 }
100 used += cellWidth
101 start--
102 }
103 return string(runes[start:])
104}
105
106func textCellWidth(text string) int {
107 width := 0
108 for _, r := range text {
109 width += renderer.RuneWidth(r)
110 }
111 return width
112}
113
114func (m *Model) InsertSearch(r rune) {
115 if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes {
116 return
117 }
118 m.query.Insert(r)
119 m.refreshSearch(true)
120}
121
122func (m *Model) BackspaceSearch() {
123 if m == nil || !m.searchActive || m.query.Value() == "" {
124 return
125 }
126 m.query.Backspace()
127 m.refreshSearch(true)
128}
129
130func (m *Model) ClearSearch() {
131 if m == nil || !m.searchActive || m.query.Value() == "" {
132 return
133 }
134 m.query.SetValue("")
135 m.refreshSearch(false)
136}
137
138func (m *Model) MatchCount() int {
139 if m == nil || !m.searchActive {
140 return 0
141 }
142 return len(m.matchRows)
143}
144
145// ReplaceFrom applies a fresh canonical row snapshot while retaining the
146// attachment-local search editor and exact cursor when that key still exists.
147// Callers serialize this mutation with their picker ownership lock.
148func (m *Model) ReplaceFrom(next *Model) {
149 if m == nil || next == nil {
150 return
151 }
152 key, hadKey := m.cursorKey()
153 searchActive, query := m.searchActive, m.query.Value()
154 m.intent = next.intent
155 m.sort = next.sort
156 m.lines = append(m.lines[:0], next.lines...)
157 m.searchActive = searchActive
158 m.query.SetValue(query)
159 m.rebuild(key, hadKey, next.selected)
160}
161
162func (m *Model) rowMatches(idx int) bool {
163 if m == nil || !m.searchActive || m.query.Value() == "" {
164 return true
165 }
166 _, ok := m.searchMatches[idx]
167 return ok
168}
169
170func (m *Model) moveSearch(delta int) {
171 if m == nil {
172 return
173 }
174 if len(m.matchRows) == 0 {
175 // The query shows nothing, so no row may hold the cursor.
176 m.selected = -1
177 return
178 }
179 position := slices.Index(m.matchRows, m.selected)
180 if position < 0 {
181 if delta > 0 {
182 m.selected = m.matchRows[0]
183 } else {
184 m.selected = m.matchRows[len(m.matchRows)-1]
185 }
186 return
187 }
188 next := position + delta
189 if next >= 0 && next < len(m.matchRows) {
190 m.selected = m.matchRows[next]
191 }
192}
193
194// refreshSearch recomputes the rows the active query shows and re-places the
195// cursor: the editor never leaves it on a row the query hides.
196func (m *Model) refreshSearch(selectBest bool) int {
197 m.searchMatches = make(map[int]searchMatch)
198 m.matchRows = make([]int, 0, len(m.rows))
199 query := strings.ToLower(m.query.Value())
200 if query == "" {
201 for idx, row := range m.rows {
202 if row.focusable() {
203 m.matchRows = append(m.matchRows, idx)
204 }
205 }
206 if selectBest {
207 m.normalizeCursor(-1)
208 }
209 return -1
210 }
211
212 needleRunes := []rune(query)
213 bestIdx := -1
214 var best fieldMatch
215 for idx, row := range m.rows {
216 if !row.focusable() {
217 continue
218 }
219 matched, ok := matchRow(row, query, needleRunes)
220 if !ok {
221 continue
222 }
223 m.searchMatches[idx] = matched
224 m.matchRows = append(m.matchRows, idx)
225 if bestIdx < 0 || lessFieldMatch(matched.best, best) {
226 bestIdx, best = idx, matched.best
227 }
228 }
229 if selectBest {
230 if bestIdx >= 0 {
231 m.selected = bestIdx
232 } else {
233 m.selected = -1
234 }
235 m.normalizeCursor(bestIdx)
236 }
237 return bestIdx
238}
239
240// normalizeCursor places the cursor on a row the active query still shows. It
241// keeps the current row when it qualifies, otherwise it takes the best match,
242// then the nearest eligible row, and finally clears the cursor when nothing
243// qualifies.
244func (m *Model) normalizeCursor(best int) {
245 if m == nil || m.eligible(m.selected) {
246 return
247 }
248 if best >= 0 {
249 m.selected = best
250 return
251 }
252 hint := m.selected
253 m.selected = -1
254 m.SelectNearestRow(hint)
255 if m.selected < 0 {
256 m.selected = m.firstEligible()
257 }
258}
259
260func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
261 fields := []struct {
262 kind matchField
263 text string
264 }{
265 {matchName, row.foldedLabel},
266 {matchDetail, row.foldedDetail},
267 {matchContext, row.foldedContext},
268 }
269
270 var result searchMatch
271 for _, field := range fields {
272 match, ok := scoreField(field.kind, field.text, query, needleRunes)
273 if !ok {
274 continue
275 }
276 result.fieldMatches = append(result.fieldMatches, match)
277 if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
278 result.best = match
279 }
280 }
281 return result, len(result.fieldMatches) != 0
282}
283
284func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
285 if len(needle) == 0 {
286 return fieldMatch{field: field}, true
287 }
288 if text == query {
289 positions := rangeRunePositions(len(needle))
290 return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
291 }
292 if strings.HasPrefix(text, query) {
293 positions := rangeRunePositions(len(needle))
294 return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
295 }
296 positions, ok := subsequenceRunePositions([]rune(text), needle)
297 if !ok {
298 return fieldMatch{}, false
299 }
300 return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true
301}
302
303func lessFieldMatch(left, right fieldMatch) bool {
304 if left.rank != right.rank {
305 return left.rank < right.rank
306 }
307 if left.span != right.span {
308 return left.span < right.span
309 }
310 if left.first != right.first {
311 return left.first < right.first
312 }
313 return left.field < right.field
314}
315
316func rangeRunePositions(length int) []int {
317 positions := make([]int, length)
318 for i := range positions {
319 positions[i] = i
320 }
321 return positions
322}
323
324func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
325 if len(needle) == 0 {
326 return nil, true
327 }
328 positions := make([]int, 0, len(needle))
329 next := 0
330 for idx, r := range haystack {
331 if r != needle[next] {
332 continue
333 }
334 positions = append(positions, idx)
335 next++
336 if next == len(needle) {
337 return positions, true
338 }
339 }
340 return nil, false
341}