1package modes
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/mattn/go-runewidth"
8
9 "github.com/patriceckhart/zot/packages/tui"
10)
11
12type settingsDialog struct {
13 active bool
14 title string
15 items []settingsItem
16 cursor int
17 selecting bool
18 direct bool
19 optionCursor int
20 parentItems []settingsItem
21 parentCursor int
22
23 // MaxRows caps how many rows the dialog renders. The host sets it
24 // from the terminal height before each render; zero renders the
25 // list in full. A list taller than the cap scrolls in a window
26 // that follows the cursor so entries never disappear off-screen.
27 MaxRows int
28
29 // viewTop is the index of the first entry drawn in the current
30 // window. It survives across renders so the window only moves when
31 // the cursor pushes past one of its edges, and is reset whenever
32 // the dialog swaps between the item list and an option list.
33 viewTop int
34}
35
36type settingsItem struct {
37 key string
38 label string
39 desc string
40 value bool
41 options []settingsOption
42 children []settingsItem
43 picker bool
44 choice int
45 disabled bool
46 hint string
47}
48
49type settingsOption struct {
50 value string
51 label string
52 desc string
53}
54
55type settingsAction struct {
56 Toggle bool
57 Key string
58 Value bool
59 StringValue string
60 ModelShortcutSlot int
61 Close bool
62}
63
64func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
65
66func (d *settingsDialog) Open(items []settingsItem) bool {
67 if len(items) == 0 {
68 return false
69 }
70 d.title = "settings"
71 d.items = items
72 d.cursor = 0
73 d.selecting = false
74 d.direct = false
75 d.optionCursor = 0
76 d.parentItems = nil
77 d.parentCursor = 0
78 d.viewTop = 0
79 d.active = true
80 return true
81}
82
83func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
84 if len(item.options) == 0 {
85 return false
86 }
87 d.title = item.label
88 d.items = []settingsItem{item}
89 d.cursor = 0
90 d.selecting = true
91 d.direct = true
92 d.optionCursor = item.choice
93 if d.optionCursor < 0 || d.optionCursor >= len(item.options) {
94 d.optionCursor = 0
95 }
96 d.parentItems = nil
97 d.parentCursor = 0
98 d.viewTop = 0
99 d.active = true
100 return true
101}
102
103func (d *settingsDialog) Close() {
104 d.active = false
105 d.selecting = false
106 d.direct = false
107 d.parentItems = nil
108 d.viewTop = 0
109}
110func (d *settingsDialog) Active() bool { return d != nil && d.active }
111
112func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
113 if d.selecting {
114 return d.handleOptionKey(k)
115 }
116 switch k.Kind {
117 case tui.KeyUp:
118 if d.cursor > 0 {
119 d.cursor--
120 }
121 case tui.KeyDown:
122 if d.cursor < len(d.items)-1 {
123 d.cursor++
124 }
125 case tui.KeyBackspace:
126 if len(d.items) > 0 {
127 it := d.items[d.cursor]
128 if strings.HasPrefix(it.key, "quick_model_") {
129 return settingsAction{Toggle: true, Key: it.key, StringValue: ""}
130 }
131 }
132 case tui.KeyEsc:
133 if len(d.parentItems) > 0 {
134 d.items = d.parentItems
135 d.cursor = d.parentCursor
136 d.parentItems = nil
137 d.parentCursor = 0
138 d.viewTop = 0
139 d.title = "settings"
140 return settingsAction{}
141 }
142 d.Close()
143 return settingsAction{Close: true}
144 case tui.KeyEnter:
145 return d.toggleCurrent()
146 case tui.KeyRune:
147 if k.Rune == ' ' {
148 return d.toggleCurrent()
149 }
150 }
151 return settingsAction{}
152}
153
154func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
155 it := d.items[d.cursor]
156 switch k.Kind {
157 case tui.KeyUp:
158 if d.optionCursor > 0 {
159 d.optionCursor--
160 }
161 case tui.KeyDown:
162 if d.optionCursor < len(it.options)-1 {
163 d.optionCursor++
164 }
165 case tui.KeyEsc:
166 if d.direct {
167 d.Close()
168 return settingsAction{Close: true}
169 }
170 d.selecting = false
171 d.viewTop = 0
172 case tui.KeyEnter:
173 return d.selectCurrentOption()
174 case tui.KeyRune:
175 if k.Rune == ' ' {
176 return d.selectCurrentOption()
177 }
178 }
179 return settingsAction{}
180}
181
182func (d *settingsDialog) toggleCurrent() settingsAction {
183 if len(d.items) == 0 {
184 d.Close()
185 return settingsAction{Close: true}
186 }
187 it := d.items[d.cursor]
188 if it.disabled {
189 return settingsAction{}
190 }
191 if it.picker {
192 slotText := strings.TrimPrefix(it.key, "quick_model_")
193 slot := 0
194 for _, r := range slotText {
195 if r < '0' || r > '9' {
196 slot = 0
197 break
198 }
199 slot = slot*10 + int(r-'0')
200 }
201 return settingsAction{ModelShortcutSlot: slot}
202 }
203 if len(it.children) > 0 {
204 d.parentItems = d.items
205 d.parentCursor = d.cursor
206 d.items = it.children
207 d.cursor = 0
208 d.optionCursor = 0
209 d.viewTop = 0
210 d.title = "settings: " + it.label
211 return settingsAction{}
212 }
213 if len(it.options) > 0 {
214 d.optionCursor = it.choice
215 if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
216 d.optionCursor = 0
217 }
218 d.selecting = true
219 d.viewTop = 0
220 return settingsAction{}
221 }
222 it.value = !it.value
223 d.items[d.cursor] = it
224 return settingsAction{Toggle: true, Key: it.key, Value: it.value}
225}
226
227func (d *settingsDialog) selectCurrentOption() settingsAction {
228 if len(d.items) == 0 {
229 d.Close()
230 return settingsAction{Close: true}
231 }
232 it := d.items[d.cursor]
233 if len(it.options) == 0 {
234 d.selecting = false
235 return settingsAction{}
236 }
237 if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
238 d.optionCursor = 0
239 }
240 it.choice = d.optionCursor
241 d.items[d.cursor] = it
242 d.selecting = false
243 d.viewTop = 0
244 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
245 if d.direct {
246 d.Close()
247 action.Close = true
248 }
249 return action
250}
251
252func (d *settingsDialog) Render(th tui.Theme, width int) []string {
253 if !d.Active() {
254 return nil
255 }
256 if d.selecting {
257 return d.renderOptions(th, width)
258 }
259 var lines []string
260 lines = append(lines, frameHeader(th, d.title, width))
261 if len(d.parentItems) > 0 {
262 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:"))
263 } else {
264 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
265 }
266
267 // Render every entry to its own block up front so the row window
268 // can count wrapped descriptions, then draw only the blocks that
269 // fit the budget. Windowing keeps the cursor entry on screen: a
270 // list taller than the terminal scrolls instead of having its top
271 // entries clipped off and unreachable.
272 blocks := make([][]string, len(d.items))
273 heights := make([]int, len(d.items))
274 for i := range d.items {
275 blocks[i] = d.itemBlock(th, width, i)
276 heights[i] = len(blocks[i])
277 }
278 budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
279 start, end := d.windowBlocks(heights, d.cursor, budget)
280 lines = append(lines, windowLines(th, blocks, start, end, budget)...)
281 lines = append(lines, frameRule(th, width))
282 return lines
283}
284
285// itemBlock renders one entry as its label row followed by any wrapped
286// description rows. Keeping both in one block lets the row window
287// count the description and trim it when space runs out without losing
288// the label row the cursor highlight sits on.
289func (d *settingsDialog) itemBlock(th tui.Theme, width, idx int) []string {
290 it := d.items[idx]
291 box := "[ ]"
292 if it.value {
293 box = "[✓]"
294 }
295 plain := " " + box + " " + it.label
296 if it.picker || len(it.children) > 0 {
297 box = "[→]"
298 plain = " " + box + " " + it.label
299 } else if len(it.options) > 0 {
300 box = "[→]"
301 if it.choice < 0 || it.choice >= len(it.options) {
302 it.choice = 0
303 }
304 plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
305 }
306 if it.hint != "" {
307 plain += " " + th.FG256(th.Muted, "("+it.hint+")")
308 }
309 line := plain
310 switch {
311 case it.disabled:
312 line = th.FG256(th.Muted, plain)
313 case idx == d.cursor:
314 line = th.PadHighlight(plain, width)
315 }
316 block := []string{line}
317 for _, desc := range wrapSettingDescription(it.desc, width, 6) {
318 block = append(block, th.FG256(th.Muted, desc))
319 }
320 return block
321}
322
323// rowBudget returns the rows left for the window after the fixed rows
324// around it (frame header, hint, closing rule) are counted against
325// MaxRows. Zero means unbounded.
326func (d *settingsDialog) rowBudget(fixed int) int {
327 if d.MaxRows <= 0 {
328 return 0
329 }
330 if budget := d.MaxRows - fixed; budget > 0 {
331 return budget
332 }
333 return 1
334}
335
336// windowBlocks returns the [start, end) range of entry blocks to draw
337// so the cursor entry stays visible. Heights are the rendered row
338// counts per entry, budget is the rows available for the blocks plus
339// the "more above/below" markers, and zero means windowing is off.
340func (d *settingsDialog) windowBlocks(heights []int, cursor, budget int) (start, end int) {
341 total := len(heights)
342 if total == 0 {
343 return 0, 0
344 }
345 if budget <= 0 {
346 d.viewTop = 0
347 return 0, total
348 }
349 if cursor < 0 {
350 cursor = 0
351 }
352 if cursor >= total {
353 cursor = total - 1
354 }
355 rows := 0
356 for _, h := range heights {
357 rows += h
358 }
359 if rows <= budget {
360 d.viewTop = 0
361 return 0, total
362 }
363 // Entries are hidden above and/or below, so hold back a row for
364 // the markers. On a terminal too short for the cursor entry plus
365 // markers, drop the reservation: the highlighted row matters more
366 // than the hint that more entries exist.
367 inner := budget - 2
368 if inner < heights[cursor] {
369 inner = budget
370 }
371 return d.anchorBlocks(heights, cursor, inner)
372}
373
374// anchorBlocks returns the block range of combined height <= budget
375// that keeps the cursor entry visible. The window extends downward
376// from the persistent viewTop; when the cursor moves past either edge
377// the window re-anchors so the cursor entry becomes the first or last
378// visible one. viewTop is updated for the next render so the window
379// stays put while the cursor moves inside it.
380func (d *settingsDialog) anchorBlocks(heights []int, cursor, budget int) (start, end int) {
381 total := len(heights)
382 start = d.viewTop
383 if start > cursor || start >= total {
384 start = cursor
385 }
386 used := 0
387 for end = start; end < total && (end == start || used+heights[end] <= budget); end++ {
388 used += heights[end]
389 }
390 if cursor >= end {
391 // The cursor fell past the bottom edge: re-anchor on it and
392 // fill the remaining budget with entries above.
393 start, end = cursor, cursor+1
394 used = heights[cursor]
395 for start > 0 && used+heights[start-1] <= budget {
396 start--
397 used += heights[start]
398 }
399 }
400 d.viewTop = start
401 return start, end
402}
403
404// windowLines renders the [start, end) slice of entry blocks, framed by
405// muted "more above/below" markers when entries are hidden on either
406// side. Blocks are trimmed so the result never uses more than budget
407// rows; budget <= 0 means unbounded.
408func windowLines(th tui.Theme, blocks [][]string, start, end, budget int) []string {
409 unbounded := budget <= 0
410 remaining := budget
411 var lines []string
412 // Spend a row on the above marker only when the first block still
413 // gets a row afterwards: on a terminal this short the highlight
414 // matters more than the hint that entries are hidden.
415 if start > 0 && (unbounded || remaining > 1) {
416 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
417 remaining--
418 }
419 for i := start; i < end; i++ {
420 block := blocks[i]
421 if !unbounded {
422 if remaining <= 0 {
423 break
424 }
425 if len(block) > remaining {
426 block = block[:remaining]
427 }
428 }
429 lines = append(lines, block...)
430 remaining -= len(block)
431 }
432 if end < len(blocks) && (unbounded || remaining > 0) {
433 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(blocks)-end)))
434 }
435 return lines
436}
437
438func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
439 if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
440 d.selecting = false
441 return d.Render(th, width)
442 }
443 it := d.items[d.cursor]
444 title := "settings: " + it.label
445 if d.direct {
446 title = d.title
447 }
448 lines := []string{frameHeader(th, title, width)}
449 // Keep room for the hint, closing rule, and at least the selected
450 // option's label before spending a row on the optional description.
451 if it.desc != "" && (d.MaxRows <= 0 || d.MaxRows >= 5) {
452 lines = append(lines, th.FG256(th.Muted, it.desc))
453 }
454 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
455 blocks := make([][]string, len(it.options))
456 heights := make([]int, len(it.options))
457 for idx, opt := range it.options {
458 marker := " "
459 if idx == it.choice {
460 marker = "✓ "
461 }
462 plain := " " + marker + opt.label
463 block := make([]string, 0, 4)
464 if idx == d.optionCursor {
465 block = append(block, th.PadHighlight(plain, width))
466 } else {
467 block = append(block, plain)
468 }
469 for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
470 block = append(block, th.FG256(th.Muted, desc))
471 }
472 blocks[idx] = block
473 heights[idx] = len(block)
474 }
475 // Same windowing as the item list: a theme list longer than the
476 // terminal scrolls with the cursor instead of losing its head.
477 budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
478 start, end := d.windowBlocks(heights, d.optionCursor, budget)
479 lines = append(lines, windowLines(th, blocks, start, end, budget)...)
480 lines = append(lines, frameRule(th, width))
481 return lines
482}
483
484func wrapSettingDescription(desc string, width, indent int) []string {
485 prefix := strings.Repeat(" ", indent)
486 limit := width - indent
487 if limit < 20 {
488 limit = 20
489 }
490 words := strings.Fields(desc)
491 if len(words) == 0 {
492 return nil
493 }
494 var lines []string
495 line := words[0]
496 for _, word := range words[1:] {
497 candidate := line + " " + word
498 if runewidth.StringWidth(candidate) <= limit {
499 line = candidate
500 continue
501 }
502 lines = append(lines, prefix+line)
503 line = word
504 }
505 lines = append(lines, prefix+line)
506 return lines
507}