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}
130
131// paletteInputPrefix leads the query line on row 0 of the rendered frame.
132const paletteInputPrefix = "> "
133
134// Caret is the query caret column on row 0 of a Render frame of size inner
135// (see ui.InputCaret).
136func (m *Model) Caret(inner domain.Size) (int, bool) {
137 return ui.InputCaret(inner, paletteInputPrefix, m.Query())
138}
139func (m *Model) Up() {
140 if m == nil || len(m.matches) == 0 {
141 return
142 }
143 if !m.selectionMade {
144 m.selected = len(m.matches) - 1
145 m.selectionMade = true
146 m.clamp()
147 return
148 }
149 if m.selected > 0 {
150 m.selected--
151 m.clamp()
152 }
153}
154func (m *Model) Down() {
155 if m == nil || len(m.matches) == 0 {
156 return
157 }
158 if !m.selectionMade {
159 m.selected = 0
160 m.selectionMade = true
161 m.clamp()
162 return
163 }
164 if m.selected+1 < len(m.matches) {
165 m.selected++
166 m.clamp()
167 }
168}
169
170// Selected returns the typed immutable target, not an executable command.
171func (m *Model) Selected() (Result, bool) {
172 if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
173 return Result{}, false
174 }
175 return m.matches[m.selected].Result, true
176}
177
178// CompleteSelected only completes static command results. Session targets can
179// never be parsed, completed, or otherwise treated as commands.
180func (m *Model) CompleteSelected() bool {
181 selected, ok := m.Selected()
182 if !ok {
183 return false
184 }
185 cmd, ok := selected.Command()
186 if !ok {
187 return false
188 }
189
190 query := m.Query()
191 completed := cmd.Code
192 if cmd.Arguments != command.ArgumentsNone {
193 query = strings.TrimLeftFunc(query, unicode.IsSpace)
194 token, args, hasSeparator := completionParts(query)
195 if token == cmd.Code && hasSeparator && args != "" {
196 return false
197 }
198 completed += " "
199 if args != "" {
200 completed += args
201 }
202 }
203 if completed == query {
204 return false
205 }
206
207 m.input.SetValue(completed)
208 m.selected, m.scroll, m.selectionMade = 0, 0, false
209 m.refresh()
210 return true
211}
212
213// completionParts splits the first token at Unicode whitespace and returns
214// the argument beginning at its first non-whitespace byte.
215func completionParts(query string) (token, args string, hasSeparator bool) {
216 for i, r := range query {
217 if !unicode.IsSpace(r) {
218 continue
219 }
220 for j, argumentRune := range query[i:] {
221 if !unicode.IsSpace(argumentRune) {
222 return query[:i], query[i+j:], true
223 }
224 }
225 return query[:i], "", true
226 }
227 return query, "", false
228}
229
230// ArgumentCommand returns the exact argument-taking static command being entered.
231func (m *Model) ArgumentCommand() (command.Command, bool) {
232 if m == nil {
233 return command.Command{}, false
234 }
235 return ArgumentCommand(m.results, m.Query())
236}
237
238func (m *Model) Matches() []Match {
239 if m == nil {
240 return nil
241 }
242 out := make([]Match, len(m.matches))
243 for i, match := range m.matches {
244 out[i] = match
245 out[i].Positions = append([]int(nil), match.Positions...)
246 }
247 return out
248}
249
250func (m *Model) refresh() {
251 query := m.input.Value()
252 wasDestinationMode := m.destinationMode
253 if destinations, ok := m.cnsDestinationMatches(query); ok {
254 m.destinationMode = true
255 if !wasDestinationMode {
256 m.selectionMade = false
257 }
258 m.matches = destinations
259 m.clamp()
260 return
261 }
262 m.destinationMode = false
263 m.selectionMade = true
264 m.matches = Fuzzy(m.results, query)
265 // Keep an exact command row selected over fuzzy session matches.
266 if result, ok := ExactCommandResult(m.results, query); ok {
267 m.prependMatch(result)
268 // Once an argument-taking static token is exact, retain its row while its
269 // arguments make ordinary fuzzy matching inapplicable.
270 } else if result, _, ok := argumentResult(m.results, query); ok {
271 m.prependMatch(result)
272 } else if len(m.matches) == 0 {
273 // Arguments are not part of static command matching. When they would
274 // otherwise clear the list, keep argument-capable candidates matched by
275 // the partial first token so Tab can complete them.
276 token, _, hasSeparator := completionParts(query)
277 if hasSeparator {
278 m.matches = Fuzzy(m.argumentResults, token)
279 }
280 }
281 m.clamp()
282}
283
284func splitResults(results []Result) (ordinary, destinations []Result) {
285 ordinary = make([]Result, 0, len(results))
286 for _, result := range results {
287 if result.Kind() == ResultKindCreateSessionDestination {
288 destinations = append(destinations, result)
289 continue
290 }
291 ordinary = append(ordinary, result)
292 }
293 return ordinary, destinations
294}
295
296func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
297 if len(m.createDestinations) < 2 {
298 return nil, false
299 }
300 fields := strings.Fields(query)
301 if len(fields) == 0 || len(fields) > 2 {
302 return nil, false
303 }
304 cmd, ok := ArgumentCommand(m.results, query)
305 if !ok || cmd.Slug != "new-session" {
306 return nil, false
307 }
308 name := ""
309 if len(fields) == 2 {
310 name = fields[1]
311 if domain.ValidateSessionName(name) != nil {
312 return nil, false
313 }
314 }
315 matches := make([]Match, len(m.createDestinations))
316 for i, destination := range m.createDestinations {
317 matches[i] = newMatch(destination.withCreateSessionName(name), 0)
318 }
319 return matches, true
320}
321
322func requiredArgumentResults(results []Result) []Result {
323 commands := make([]Result, 0, len(results))
324 for _, result := range results {
325 cmd, ok := result.Command()
326 if ok && cmd.Arguments != command.ArgumentsNone {
327 commands = append(commands, result)
328 }
329 }
330 return commands
331}
332
333func (m *Model) prependMatch(result Result) {
334 cmd, _ := result.Command()
335 for i, match := range m.matches {
336 candidate, ok := match.Result.Command()
337 if !ok || candidate.Code != cmd.Code {
338 continue
339 }
340 if i > 0 {
341 copy(m.matches[1:i+1], m.matches[:i])
342 m.matches[0] = match
343 }
344 return
345 }
346 m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
347}
348func (m *Model) clamp() {
349 if len(m.matches) == 0 {
350 m.selected, m.scroll = -1, 0
351 return
352 }
353 if m.selected < 0 {
354 m.selected = 0
355 }
356 if m.selected >= len(m.matches) {
357 m.selected = len(m.matches) - 1
358 }
359 if m.scroll < 0 {
360 m.scroll = 0
361 }
362 if m.scroll > m.selected {
363 m.scroll = m.selected
364 }
365}
366
367func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
368 styles := opts.Styles
369 if styles == (RenderStyles{}) {
370 styles = DefaultRenderStyles()
371 }
372 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
373 if frame.Width == 0 || frame.Height == 0 {
374 return frame
375 }
376 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
377 selectionDesc := styles.SelectionDescription
378 if selectionDesc == (renderer.Style{}) {
379 selectionDesc = mergePaletteDescStyle(selection, desc)
380 }
381 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
382 if m == nil {
383 ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
384 return frame
385 }
386 ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
387 start, visible := 1, frame.Height-1
388 if visible <= 0 || len(m.matches) == 0 {
389 return frame
390 }
391 if m.selected < m.scroll {
392 m.scroll = m.selected
393 }
394 if m.selected >= m.scroll+visible {
395 m.scroll = m.selected - visible + 1
396 }
397 codeWidth := 0
398 for _, match := range m.matches {
399 if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
400 codeWidth = utf8.RuneCountInString(cmd.Code)
401 }
402 }
403 activeCmd, activeOK := m.ArgumentCommand()
404 for y := range visible {
405 idx := m.scroll + y
406 if idx >= len(m.matches) {
407 break
408 }
409 match := m.matches[idx]
410 style := row
411 rowSelected := idx == m.selected && m.selectionMade
412 if rowSelected {
413 style = selection
414 }
415 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
416 if cmd, ok := match.Result.Command(); ok {
417 m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
418 continue
419 }
420 showFeedback := rowSelected || !m.selectionMade && idx == 0
421 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
422 }
423 return frame
424}
425
426func (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) {
427 x, nextHighlight := 0, 0
428 for _, r := range cmd.Code {
429 cellStyle := style
430 cellStyle.Bold = true
431 if nextHighlight < len(positions) && positions[nextHighlight] == x {
432 cellStyle = selection
433 cellStyle.Bold = true
434 nextHighlight++
435 }
436 if x < frame.Width {
437 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
438 }
439 x++
440 }
441 for x < codeWidth+1 && x < frame.Width {
442 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
443 x++
444 }
445 description := cmd.Desc
446 if feedback != "" && selected {
447 description = feedback
448 } else if preview != "" && selected {
449 description = preview
450 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
451 description = guidance
452 }
453 descriptionStyle := mergePaletteDescStyle(style, desc)
454 if selected {
455 descriptionStyle = selectionDesc
456 }
457 ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
458}
459
460func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
461 x, nextHighlight := 0, 0
462 for _, r := range match.Result.DisplayText() {
463 cellStyle := style
464 if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
465 cellStyle = selection
466 cellStyle.Bold = true
467 nextHighlight++
468 }
469 if x < frame.Width {
470 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
471 }
472 x++
473 }
474 if feedback == "" || !selected {
475 return
476 }
477 if x < frame.Width {
478 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
479 }
480 x++
481 for _, r := range feedback {
482 if x < frame.Width {
483 frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
484 }
485 x++
486 }
487}
488
489func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
490 out := line
491 out.Italic = desc.Italic
492 if desc.HasForegroundRGB {
493 out.HasForegroundRGB = true
494 out.ForegroundRGB = desc.ForegroundRGB
495 out.Foreground = -1
496 } else if desc.Foreground >= 0 {
497 out.HasForegroundRGB = false
498 out.Foreground = desc.Foreground
499 }
500 return out
501}