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}