v0.9.2 49bea57

settings_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"strings"
5
6	"github.com/mattn/go-runewidth"
7
8	"github.com/bnema/zut/packages/tui"
9)
10
11type settingsDialog struct {
12	active       bool
13	title        string
14	items        []settingsItem
15	cursor       int
16	selecting    bool
17	direct       bool
18	optionCursor int
19	parentItems  []settingsItem
20	parentCursor int
21}
22
23type settingsItem struct {
24	key      string
25	label    string
26	desc     string
27	value    bool
28	options  []settingsOption
29	children []settingsItem
30	picker   bool
31	choice   int
32	disabled bool
33	hint     string
34}
35
36type settingsOption struct {
37	value string
38	label string
39	desc  string
40}
41
42type settingsAction struct {
43	Toggle            bool
44	Key               string
45	Value             bool
46	StringValue       string
47	ModelShortcutSlot int
48	Close             bool
49}
50
51func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
52
53func (d *settingsDialog) Open(items []settingsItem) bool {
54	if len(items) == 0 {
55		return false
56	}
57	d.title = "settings"
58	d.items = items
59	d.cursor = 0
60	d.selecting = false
61	d.direct = false
62	d.optionCursor = 0
63	d.parentItems = nil
64	d.parentCursor = 0
65	d.active = true
66	return true
67}
68
69func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
70	if len(item.options) == 0 {
71		return false
72	}
73	d.title = item.label
74	d.items = []settingsItem{item}
75	d.cursor = 0
76	d.selecting = true
77	d.direct = true
78	d.optionCursor = item.choice
79	if d.optionCursor < 0 || d.optionCursor >= len(item.options) {
80		d.optionCursor = 0
81	}
82	d.parentItems = nil
83	d.parentCursor = 0
84	d.active = true
85	return true
86}
87
88func (d *settingsDialog) Close() {
89	d.active = false
90	d.selecting = false
91	d.direct = false
92	d.parentItems = nil
93}
94func (d *settingsDialog) Active() bool { return d != nil && d.active }
95
96func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
97	if d.selecting {
98		return d.handleOptionKey(k)
99	}
100	switch k.Kind {
101	case tui.KeyUp:
102		if d.cursor > 0 {
103			d.cursor--
104		}
105	case tui.KeyDown:
106		if d.cursor < len(d.items)-1 {
107			d.cursor++
108		}
109	case tui.KeyBackspace:
110		if len(d.items) > 0 {
111			it := d.items[d.cursor]
112			if strings.HasPrefix(it.key, "quick_model_") {
113				return settingsAction{Toggle: true, Key: it.key, StringValue: ""}
114			}
115		}
116	case tui.KeyEsc:
117		if len(d.parentItems) > 0 {
118			d.items = d.parentItems
119			d.cursor = d.parentCursor
120			d.parentItems = nil
121			d.parentCursor = 0
122			d.title = "settings"
123			return settingsAction{}
124		}
125		d.Close()
126		return settingsAction{Close: true}
127	case tui.KeyEnter:
128		return d.toggleCurrent()
129	case tui.KeyRune:
130		if k.Rune == ' ' {
131			return d.toggleCurrent()
132		}
133	}
134	return settingsAction{}
135}
136
137func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
138	it := d.items[d.cursor]
139	switch k.Kind {
140	case tui.KeyUp:
141		if d.optionCursor > 0 {
142			d.optionCursor--
143		}
144	case tui.KeyDown:
145		if d.optionCursor < len(it.options)-1 {
146			d.optionCursor++
147		}
148	case tui.KeyEsc:
149		if d.direct {
150			d.Close()
151			return settingsAction{Close: true}
152		}
153		d.selecting = false
154	case tui.KeyEnter:
155		return d.selectCurrentOption()
156	case tui.KeyRune:
157		if k.Rune == ' ' {
158			return d.selectCurrentOption()
159		}
160	}
161	return settingsAction{}
162}
163
164func (d *settingsDialog) toggleCurrent() settingsAction {
165	if len(d.items) == 0 {
166		d.Close()
167		return settingsAction{Close: true}
168	}
169	it := d.items[d.cursor]
170	if it.disabled {
171		return settingsAction{}
172	}
173	if it.picker {
174		slotText := strings.TrimPrefix(it.key, "quick_model_")
175		slot := 0
176		for _, r := range slotText {
177			if r < '0' || r > '9' {
178				slot = 0
179				break
180			}
181			slot = slot*10 + int(r-'0')
182		}
183		return settingsAction{ModelShortcutSlot: slot}
184	}
185	if len(it.children) > 0 {
186		d.parentItems = d.items
187		d.parentCursor = d.cursor
188		d.items = it.children
189		d.cursor = 0
190		d.optionCursor = 0
191		d.title = "settings: " + it.label
192		return settingsAction{}
193	}
194	if len(it.options) > 0 {
195		d.optionCursor = it.choice
196		if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
197			d.optionCursor = 0
198		}
199		d.selecting = true
200		return settingsAction{}
201	}
202	it.value = !it.value
203	d.items[d.cursor] = it
204	return settingsAction{Toggle: true, Key: it.key, Value: it.value}
205}
206
207func (d *settingsDialog) selectCurrentOption() settingsAction {
208	if len(d.items) == 0 {
209		d.Close()
210		return settingsAction{Close: true}
211	}
212	it := d.items[d.cursor]
213	if len(it.options) == 0 {
214		d.selecting = false
215		return settingsAction{}
216	}
217	if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
218		d.optionCursor = 0
219	}
220	it.choice = d.optionCursor
221	d.items[d.cursor] = it
222	d.selecting = false
223	action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224	if d.direct {
225		d.Close()
226		action.Close = true
227	}
228	return action
229}
230
231func (d *settingsDialog) Render(th tui.Theme, width int) []string {
232	if !d.Active() {
233		return nil
234	}
235	if d.selecting {
236		return d.renderOptions(th, width)
237	}
238	var lines []string
239	lines = append(lines, frameHeader(th, d.title, width))
240	if len(d.parentItems) > 0 {
241		lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to go back:"))
242	} else {
243		lines = append(lines, th.FGColor(th.Muted, "change with enter/space, esc to close:"))
244	}
245	for i, it := range d.items {
246		box := "[ ]"
247		if it.value {
248			box = "[✓]"
249		}
250		plain := "  " + box + " " + it.label
251		if it.picker || len(it.children) > 0 {
252			box = "[→]"
253			plain = "  " + box + " " + it.label
254		} else if len(it.options) > 0 {
255			box = "[→]"
256			if it.choice < 0 || it.choice >= len(it.options) {
257				it.choice = 0
258			}
259			plain = "  " + box + " " + it.label + ": " + it.options[it.choice].label
260		}
261		if it.hint != "" {
262			plain += "  " + th.FGColor(th.Muted, "("+it.hint+")")
263		}
264		if it.disabled {
265			lines = append(lines, th.FGColor(th.Muted, plain))
266		} else if i == d.cursor {
267			lines = append(lines, th.PadHighlight(plain, width))
268		} else {
269			lines = append(lines, plain)
270		}
271		if it.desc != "" {
272			for _, desc := range wrapSettingDescription(it.desc, width, 6) {
273				lines = append(lines, th.FGColor(th.Muted, desc))
274			}
275		}
276	}
277	lines = append(lines, frameRule(th, width))
278	return lines
279}
280
281func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
282	if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
283		d.selecting = false
284		return d.Render(th, width)
285	}
286	it := d.items[d.cursor]
287	title := "settings: " + it.label
288	if d.direct {
289		title = d.title
290	}
291	lines := []string{frameHeader(th, title, width)}
292	if it.desc != "" {
293		lines = append(lines, th.FGColor(th.Muted, it.desc))
294	}
295	lines = append(lines, th.FGColor(th.Muted, "select with enter/space, esc to go back:"))
296	for idx, opt := range it.options {
297		marker := "  "
298		if idx == it.choice {
299			marker = "✓ "
300		}
301		plain := "  " + marker + opt.label
302		if idx == d.optionCursor {
303			lines = append(lines, th.PadHighlight(plain, width))
304		} else {
305			lines = append(lines, plain)
306		}
307		if opt.desc != "" {
308			for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
309				lines = append(lines, th.FGColor(th.Muted, desc))
310			}
311		}
312	}
313	lines = append(lines, frameRule(th, width))
314	return lines
315}
316
317func wrapSettingDescription(desc string, width, indent int) []string {
318	prefix := strings.Repeat(" ", indent)
319	limit := width - indent
320	if limit < 20 {
321		limit = 20
322	}
323	words := strings.Fields(desc)
324	if len(words) == 0 {
325		return nil
326	}
327	var lines []string
328	line := words[0]
329	for _, word := range words[1:] {
330		candidate := line + " " + word
331		if runewidth.StringWidth(candidate) <= limit {
332			line = candidate
333			continue
334		}
335		lines = append(lines, prefix+line)
336		line = word
337	}
338	lines = append(lines, prefix+line)
339	return lines
340}

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 477

↑↓ moveenter openesc close