1package palette
2
3import (
4 "strings"
5 "unicode"
6 "unicode/utf8"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/command"
11 "github.com/bnema/vev/internal/usecase/ui"
12)
13
14type RenderStyles struct {
15 // Base fills unused modal interior cells; Row is the ordinary inactive
16 // result surface. They are separate to retain the chrome hierarchy.
17 Base renderer.Style
18 Row renderer.Style
19 Selection renderer.Style
20 Description renderer.Style
21 SelectionDescription renderer.Style
22}
23
24// RenderOptions contains presentation supplied by the daemon for one render.
25// Guidance is substituted only into the exact contextual command row; it never
26// adds rows or changes fuzzy matching geometry.
27type RenderOptions struct {
28 Styles RenderStyles
29 Guidance string
30 // Preview is the current exact command's action description. It replaces
31 // the selected row's static description unless Feedback is present.
32 Preview string
33 // Feedback is interaction-scoped daemon feedback. It replaces the selected
34 // row's contextual detail without adding a searchable result or row.
35 Feedback string
36}
37
38type Model struct {
39 results []Result
40 argumentResults []Result
41 createDestinations []Result
42 input ui.TextInput
43 matches []Match
44 selected int
45 scroll int
46 destinationMode bool
47 selectionMade bool
48}
49
50// New accepts typed immutable palette results.
51func New(results []Result) *Model {
52 ordinary, destinations := splitResults(results)
53 m := &Model{
54 results: ordinary,
55 argumentResults: requiredArgumentResults(ordinary),
56 createDestinations: destinations,
57 }
58 m.refresh()
59 return m
60}
61
62func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
63
64// ReplaceResults replaces the model's immutable result source while preserving
65// the current query and exact selected target when it remains available.
66func (m *Model) ReplaceResults(results []Result) {
67 if m == nil {
68 return
69 }
70 wasDestinationMode := m.destinationMode
71 wasSelectionMade := m.selectionMade
72 selected, hadSelection := m.Selected()
73 selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination
74 m.results, m.createDestinations = splitResults(results)
75 m.argumentResults = requiredArgumentResults(m.results)
76 m.refresh()
77 if !hadSelection {
78 if m.destinationMode || wasDestinationMode && !wasSelectionMade {
79 m.selectionMade = false
80 }
81 return
82 }
83 for i, match := range m.matches {
84 if match.Result.sameTarget(selected) {
85 m.selected = i
86 m.selectionMade = true
87 m.clamp()
88 return
89 }
90 }
91 if selectedDestination || m.destinationMode {
92 m.selectionMade = false
93 m.selected, m.scroll = 0, 0
94 m.clamp()
95 }
96}
97
98func DefaultRenderStyles() RenderStyles {
99 selection := renderer.DefaultStyle()
100 selection.Inverse = true
101 description := renderer.DefaultStyle()
102 description.Italic = true
103 selectionDescription := selection
104 selectionDescription.Italic = true
105 base := renderer.DefaultStyle()
106 return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
107}
108
109func (m *Model) Insert(r rune) {
110 if m != nil {
111 m.input.Insert(r)
112 m.selected, m.scroll, m.selectionMade = 0, 0, false
113 m.refresh()
114 }
115}
116func (m *Model) Backspace() {
117 if m != nil && m.input.Value() != "" {
118 m.input.Backspace()
119 m.selected, m.scroll, m.selectionMade = 0, 0, false
120 m.refresh()
121 }
122}
123
124func (m *Model) Query() string {
125 if m == nil {
126 return ""
127 }
128 return m.input.Value()
129}
130func (m *Model) Up() {
131 if m == nil || len(m.matches) == 0 {
132 return
133 }
134 if !m.selectionMade {
135 m.selected = len(m.matches) - 1
136 m.selectionMade = true
137 m.clamp()
138 return
139 }
140 if m.selected > 0 {
141 m.selected--
142 m.clamp()
143 }
144}
145func (m *Model) Down() {
146 if m == nil || len(m.matches) == 0 {
147 return
148 }
149 if !m.selectionMade {
150 m.selected = 0
151 m.selectionMade = true
152 m.clamp()
153 return
154 }
155 if m.selected+1 < len(m.matches) {
156 m.selected++
157 m.clamp()
158 }
159}
160
161// Selected returns the typed immutable target, not an executable command.
162func (m *Model) Selected() (Result, bool) {
163 if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
164 return Result{}, false
165 }
166 return m.matches[m.selected].Result, true
167}
168
169// CompleteSelected only completes static command results. Session targets can
170// never be parsed, completed, or otherwise treated as commands.
171func (m *Model) CompleteSelected() bool {
172 selected, ok := m.Selected()
173 if !ok {
174 return false
175 }
176 cmd, ok := selected.Command()
177 if !ok {
178 return false
179 }
180
181 query := m.Query()
182 completed := cmd.Code
183 if cmd.Arguments != command.ArgumentsNone {
184 query = strings.TrimLeftFunc(query, unicode.IsSpace)
185 token, args, hasSeparator := completionParts(query)
186 if token == cmd.Code && hasSeparator && args != "" {
187 return false
188 }
189 completed += " "
190 if args != "" {
191 completed += args
192 }
193 }
194 if completed == query {
195 return false
196 }
197
198 m.input.SetValue(completed)
199 m.selected, m.scroll, m.selectionMade = 0, 0, false
200 m.refresh()
201 return true
202}
203
204// completionParts splits the first token at Unicode whitespace and returns
205// the argument beginning at its first non-whitespace byte.
206func completionParts(query string) (token, args string, hasSeparator bool) {
207 for i, r := range query {
208 if !unicode.IsSpace(r) {
209 continue
210 }
211 for j, argumentRune := range query[i:] {
212 if !unicode.IsSpace(argumentRune) {
213 return query[:i], query[i+j:], true
214 }
215 }
216 return query[:i], "", true
217 }
218 return query, "", false
219}
220
221// ArgumentCommand returns the exact argument-taking static command being entered.
222func (m *Model) ArgumentCommand() (command.Command, bool) {
223 if m == nil {
224 return command.Command{}, false
225 }
226 return ArgumentCommand(m.results, m.Query())
227}
228
229func (m *Model) Matches() []Match {
230 if m == nil {
231 return nil
232 }
233 out := make([]Match, len(m.matches))
234 for i, match := range m.matches {
235 out[i] = match
236 out[i].Positions = append([]int(nil), match.Positions...)
237 }
238 return out
239}
240
241func (m *Model) refresh() {
242 query := m.input.Value()
243 wasDestinationMode := m.destinationMode
244 if destinations, ok := m.cnsDestinationMatches(query); ok {
245 m.destinationMode = true
246 if !wasDestinationMode {
247 m.selectionMade = false
248 }
249 m.matches = destinations
250 m.clamp()
251 return
252 }
253 m.destinationMode = false
254 m.selectionMade = true
255 m.matches = Fuzzy(m.results, query)
256 // Keep an exact command row selected over fuzzy session matches.
257 if result, ok := ExactCommandResult(m.results, query); ok {
258 m.prependMatch(result)
259 // Once an argument-taking static token is exact, retain its row while its
260 // arguments make ordinary fuzzy matching inapplicable.
261 } else if result, _, ok := argumentResult(m.results, query); ok {
262 m.prependMatch(result)
263 } else if len(m.matches) == 0 {
264 // Arguments are not part of static command matching. When they would
265 // otherwise clear the list, keep argument-capable candidates matched by
266 // the partial first token so Tab can complete them.
267 token, _, hasSeparator := completionParts(query)
268 if hasSeparator {
269 m.matches = Fuzzy(m.argumentResults, token)
270 }
271 }
272 m.clamp()
273}
274
275func splitResults(results []Result) (ordinary, destinations []Result) {
276 ordinary = make([]Result, 0, len(results))
277 for _, result := range results {
278 if result.Kind() == ResultKindCreateSessionDestination {
279 destinations = append(destinations, result)
280 continue
281 }
282 ordinary = append(ordinary, result)
283 }
284 return ordinary, destinations
285}
286
287func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
288 if len(m.createDestinations) < 2 {
289 return nil, false
290 }
291 fields := strings.Fields(query)
292 if len(fields) == 0 || len(fields) > 2 {
293 return nil, false
294 }
295 cmd, ok := ArgumentCommand(m.results, query)
296 if !ok || cmd.Slug != "new-session" {
297 return nil, false
298 }
299 name := ""
300 if len(fields) == 2 {
301 name = fields[1]
302 if domain.ValidateSessionName(name) != nil {
303 return nil, false
304 }
305 }
306 matches := make([]Match, len(m.createDestinations))
307 for i, destination := range m.createDestinations {
308 matches[i] = newMatch(destination.withCreateSessionName(name), 0)
309 }
310 return matches, true
311}
312
313func requiredArgumentResults(results []Result) []Result {
314 commands := make([]Result, 0, len(results))
315 for _, result := range results {
316 cmd, ok := result.Command()
317 if ok && cmd.Arguments != command.ArgumentsNone {
318 commands = append(commands, result)
319 }
320 }
321 return commands
322}
323
324func (m *Model) prependMatch(result Result) {
325 cmd, _ := result.Command()
326 for i, match := range m.matches {
327 candidate, ok := match.Result.Command()
328 if !ok || candidate.Code != cmd.Code {
329 continue
330 }
331 if i > 0 {
332 copy(m.matches[1:i+1], m.matches[:i])
333 m.matches[0] = match
334 }
335 return
336 }
337 m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
338}
339func (m *Model) clamp() {
340 if len(m.matches) == 0 {
341 m.selected, m.scroll = -1, 0
342 return
343 }
344 if m.selected < 0 {
345 m.selected = 0
346 }
347 if m.selected >= len(m.matches) {
348 m.selected = len(m.matches) - 1
349 }
350 if m.scroll < 0 {
351 m.scroll = 0
352 }
353 if m.scroll > m.selected {
354 m.scroll = m.selected
355 }
356}
357
358func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
359 styles := opts.Styles
360 if styles == (RenderStyles{}) {
361 styles = DefaultRenderStyles()
362 }
363 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
364 if frame.Width == 0 || frame.Height == 0 {
365 return frame
366 }
367 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
368 selectionDesc := styles.SelectionDescription
369 if selectionDesc == (renderer.Style{}) {
370 selectionDesc = mergePaletteDescStyle(selection, desc)
371 }
372 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373 if m == nil {
374 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
375 return frame
376 }
377 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
378 start, visible := 1, frame.Height-1
379 if visible <= 0 || len(m.matches) == 0 {
380 return frame
381 }
382 if m.selected < m.scroll {
383 m.scroll = m.selected
384 }
385 if m.selected >= m.scroll+visible {
386 m.scroll = m.selected - visible + 1
387 }
388 codeWidth := 0
389 for _, match := range m.matches {
390 if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
391 codeWidth = utf8.RuneCountInString(cmd.Code)
392 }
393 }
394 activeCmd, activeOK := m.ArgumentCommand()
395 for y := range visible {
396 idx := m.scroll + y
397 if idx >= len(m.matches) {
398 break
399 }
400 match := m.matches[idx]
401 style := row
402 rowSelected := idx == m.selected && m.selectionMade
403 if rowSelected {
404 style = selection
405 }
406 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
407 if cmd, ok := match.Result.Command(); ok {
408 m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
409 continue
410 }
411 showFeedback := rowSelected || !m.selectionMade && idx == 0
412 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
413 }
414 return frame
415}
416
417func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, preview, feedback string, selected bool) {
418 x, nextHighlight := 0, 0
419 for _, r := range cmd.Code {
420 cellStyle := style
421 cellStyle.Bold = true
422 if nextHighlight < len(positions) && positions[nextHighlight] == x {
423 cellStyle = selection
424 cellStyle.Bold = true
425 nextHighlight++
426 }
427 if x < frame.Width {
428 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
429 }
430 x++
431 }
432 for x < codeWidth+1 && x < frame.Width {
433 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
434 x++
435 }
436 description := cmd.Desc
437 if feedback != "" && selected {
438 description = feedback
439 } else if preview != "" && selected {
440 description = preview
441 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
442 description = guidance
443 }
444 descriptionStyle := mergePaletteDescStyle(style, desc)
445 if selected {
446 descriptionStyle = selectionDesc
447 }
448 ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
449}
450
451func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
452 x, nextHighlight := 0, 0
453 for _, r := range match.Result.DisplayText() {
454 cellStyle := style
455 if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
456 cellStyle = selection
457 cellStyle.Bold = true
458 nextHighlight++
459 }
460 if x < frame.Width {
461 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
462 }
463 x++
464 }
465 if feedback == "" || !selected {
466 return
467 }
468 if x < frame.Width {
469 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
470 }
471 x++
472 for _, r := range feedback {
473 if x < frame.Width {
474 frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
475 }
476 x++
477 }
478}
479
480func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
481 out := line
482 out.Italic = desc.Italic
483 if desc.HasForegroundRGB {
484 out.HasForegroundRGB = true
485 out.ForegroundRGB = desc.ForegroundRGB
486 out.Foreground = -1
487 } else if desc.Foreground >= 0 {
488 out.HasForegroundRGB = false
489 out.Foreground = desc.Foreground
490 }
491 return out
492}