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