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 input ui.TextInput
42 matches []Match
43 selected int
44 scroll int
45}
46
47// New accepts typed immutable palette results.
48func New(results []Result) *Model {
49 copied := append([]Result(nil), results...)
50 m := &Model{
51 results: copied,
52 argumentResults: requiredArgumentResults(copied),
53 }
54 m.refresh()
55 return m
56}
57
58func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
59
60func DefaultRenderStyles() RenderStyles {
61 selection := renderer.DefaultStyle()
62 selection.Inverse = true
63 description := renderer.DefaultStyle()
64 description.Italic = true
65 selectionDescription := selection
66 selectionDescription.Italic = true
67 base := renderer.DefaultStyle()
68 return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
69}
70
71func (m *Model) Insert(r rune) {
72 if m != nil {
73 m.input.Insert(r)
74 m.selected, m.scroll = 0, 0
75 m.refresh()
76 }
77}
78func (m *Model) Backspace() {
79 if m != nil && m.input.Value() != "" {
80 m.input.Backspace()
81 m.selected, m.scroll = 0, 0
82 m.refresh()
83 }
84}
85
86func (m *Model) Query() string {
87 if m == nil {
88 return ""
89 }
90 return m.input.Value()
91}
92func (m *Model) Up() {
93 if m != nil && m.selected > 0 {
94 m.selected--
95 m.clamp()
96 }
97}
98func (m *Model) Down() {
99 if m != nil && m.selected+1 < len(m.matches) {
100 m.selected++
101 m.clamp()
102 }
103}
104
105// Selected returns the typed immutable target, not an executable command.
106func (m *Model) Selected() (Result, bool) {
107 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
108 return Result{}, false
109 }
110 return m.matches[m.selected].Result, true
111}
112
113// CompleteSelected only completes static command results. Session targets can
114// never be parsed, completed, or otherwise treated as commands.
115func (m *Model) CompleteSelected() bool {
116 selected, ok := m.Selected()
117 if !ok {
118 return false
119 }
120 cmd, ok := selected.Command()
121 if !ok {
122 return false
123 }
124
125 query := m.Query()
126 completed := cmd.Code
127 if cmd.Arguments != command.ArgumentsNone {
128 query = strings.TrimLeftFunc(query, unicode.IsSpace)
129 token, args, hasSeparator := completionParts(query)
130 if token == cmd.Code && hasSeparator && args != "" {
131 return false
132 }
133 completed += " "
134 if args != "" {
135 completed += args
136 }
137 }
138 if completed == query {
139 return false
140 }
141
142 m.input.SetValue(completed)
143 m.selected, m.scroll = 0, 0
144 m.refresh()
145 return true
146}
147
148// completionParts splits the first token at Unicode whitespace and returns
149// the argument beginning at its first non-whitespace byte.
150func completionParts(query string) (token, args string, hasSeparator bool) {
151 for i, r := range query {
152 if !unicode.IsSpace(r) {
153 continue
154 }
155 for j, argumentRune := range query[i:] {
156 if !unicode.IsSpace(argumentRune) {
157 return query[:i], query[i+j:], true
158 }
159 }
160 return query[:i], "", true
161 }
162 return query, "", false
163}
164
165// ArgumentCommand returns the exact argument-taking static command being entered.
166func (m *Model) ArgumentCommand() (command.Command, bool) {
167 if m == nil {
168 return command.Command{}, false
169 }
170 return ArgumentCommand(m.results, m.Query())
171}
172
173func (m *Model) Matches() []Match {
174 if m == nil {
175 return nil
176 }
177 out := make([]Match, len(m.matches))
178 for i, match := range m.matches {
179 out[i] = match
180 out[i].Positions = append([]int(nil), match.Positions...)
181 }
182 return out
183}
184
185func (m *Model) refresh() {
186 query := m.input.Value()
187 m.matches = Fuzzy(m.results, query)
188 // Keep an exact command row selected over fuzzy session matches.
189 if result, ok := ExactCommandResult(m.results, query); ok {
190 m.prependMatch(result)
191 // Once an argument-taking static token is exact, retain its row while its
192 // arguments make ordinary fuzzy matching inapplicable.
193 } else if result, _, ok := argumentResult(m.results, query); ok {
194 m.prependMatch(result)
195 } else if len(m.matches) == 0 {
196 // Arguments are not part of static command matching. When they would
197 // otherwise clear the list, keep argument-capable candidates matched by
198 // the partial first token so Tab can complete them.
199 token, _, hasSeparator := completionParts(query)
200 if hasSeparator {
201 m.matches = Fuzzy(m.argumentResults, token)
202 }
203 }
204 m.clamp()
205}
206
207func requiredArgumentResults(results []Result) []Result {
208 commands := make([]Result, 0, len(results))
209 for _, result := range results {
210 cmd, ok := result.Command()
211 if ok && cmd.Arguments != command.ArgumentsNone {
212 commands = append(commands, result)
213 }
214 }
215 return commands
216}
217
218func (m *Model) prependMatch(result Result) {
219 cmd, _ := result.Command()
220 for i, match := range m.matches {
221 candidate, ok := match.Result.Command()
222 if !ok || candidate.Code != cmd.Code {
223 continue
224 }
225 if i > 0 {
226 copy(m.matches[1:i+1], m.matches[:i])
227 m.matches[0] = match
228 }
229 return
230 }
231 m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
232}
233func (m *Model) clamp() {
234 if len(m.matches) == 0 {
235 m.selected, m.scroll = -1, 0
236 return
237 }
238 if m.selected < 0 {
239 m.selected = 0
240 }
241 if m.selected >= len(m.matches) {
242 m.selected = len(m.matches) - 1
243 }
244 if m.scroll < 0 {
245 m.scroll = 0
246 }
247 if m.scroll > m.selected {
248 m.scroll = m.selected
249 }
250}
251
252func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
253 styles := opts.Styles
254 if styles == (RenderStyles{}) {
255 styles = DefaultRenderStyles()
256 }
257 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
258 if frame.Width == 0 || frame.Height == 0 {
259 return frame
260 }
261 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
262 selectionDesc := styles.SelectionDescription
263 if selectionDesc == (renderer.Style{}) {
264 selectionDesc = mergePaletteDescStyle(selection, desc)
265 }
266 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
267 if m == nil {
268 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
269 return frame
270 }
271 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
272 start, visible := 1, frame.Height-1
273 if visible <= 0 || len(m.matches) == 0 {
274 return frame
275 }
276 if m.selected < m.scroll {
277 m.scroll = m.selected
278 }
279 if m.selected >= m.scroll+visible {
280 m.scroll = m.selected - visible + 1
281 }
282 codeWidth := 0
283 for _, match := range m.matches {
284 if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
285 codeWidth = utf8.RuneCountInString(cmd.Code)
286 }
287 }
288 activeCmd, activeOK := m.ArgumentCommand()
289 for y := range visible {
290 idx := m.scroll + y
291 if idx >= len(m.matches) {
292 break
293 }
294 match := m.matches[idx]
295 style := row
296 if idx == m.selected {
297 style = selection
298 }
299 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
300 if cmd, ok := match.Result.Command(); ok {
301 m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)
302 continue
303 }
304 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
305 }
306 return frame
307}
308
309func (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) {
310 x, nextHighlight := 0, 0
311 for _, r := range cmd.Code {
312 cellStyle := style
313 cellStyle.Bold = true
314 if nextHighlight < len(positions) && positions[nextHighlight] == x {
315 cellStyle = selection
316 cellStyle.Bold = true
317 nextHighlight++
318 }
319 if x < frame.Width {
320 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
321 }
322 x++
323 }
324 for x < codeWidth+1 && x < frame.Width {
325 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
326 x++
327 }
328 description := cmd.Desc
329 if feedback != "" && selected {
330 description = feedback
331 } else if preview != "" && selected {
332 description = preview
333 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
334 description = guidance
335 }
336 descriptionStyle := mergePaletteDescStyle(style, desc)
337 if selected {
338 descriptionStyle = selectionDesc
339 }
340 ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
341}
342
343func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
344 x, nextHighlight := 0, 0
345 for _, r := range match.Result.DisplayText() {
346 cellStyle := style
347 if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
348 cellStyle = selection
349 cellStyle.Bold = true
350 nextHighlight++
351 }
352 if x < frame.Width {
353 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
354 }
355 x++
356 }
357 if feedback == "" || !selected {
358 return
359 }
360 if x < frame.Width {
361 frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
362 }
363 x++
364 for _, r := range feedback {
365 if x < frame.Width {
366 frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
367 }
368 x++
369 }
370}
371
372func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
373 out := line
374 out.Italic = desc.Italic
375 if desc.HasForegroundRGB {
376 out.HasForegroundRGB = true
377 out.ForegroundRGB = desc.ForegroundRGB
378 out.Foreground = -1
379 } else if desc.Foreground >= 0 {
380 out.HasForegroundRGB = false
381 out.Foreground = desc.Foreground
382 }
383 return out
384}