v0.4.7 2ff82fa

settings_dialog.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close