@timmy6942025/cli-timer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,699 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "flag"
7
+ "fmt"
8
+ "os"
9
+ "strconv"
10
+ "strings"
11
+
12
+ "github.com/charmbracelet/bubbles/list"
13
+ "github.com/charmbracelet/bubbles/textinput"
14
+ tea "github.com/charmbracelet/bubbletea"
15
+ )
16
+
17
+ const (
18
+ defaultFont = "Standard"
19
+ defaultTickRateMs = 100
20
+ minTickRateMs = 50
21
+ maxTickRateMs = 1000
22
+ defaultCompletionMessage = "Time is up!"
23
+ )
24
+
25
+ type keybindings struct {
26
+ PauseKey string `json:"pauseKey"`
27
+ PauseAltKey string `json:"pauseAltKey"`
28
+ RestartKey string `json:"restartKey"`
29
+ ExitKey string `json:"exitKey"`
30
+ ExitAltKey string `json:"exitAltKey"`
31
+ }
32
+
33
+ var defaultKeybindings = keybindings{
34
+ PauseKey: "p",
35
+ PauseAltKey: "space",
36
+ RestartKey: "r",
37
+ ExitKey: "q",
38
+ ExitAltKey: "e",
39
+ }
40
+
41
+ type config struct {
42
+ Font string `json:"font"`
43
+ CenterDisplay bool `json:"centerDisplay"`
44
+ ShowHeader bool `json:"showHeader"`
45
+ ShowControls bool `json:"showControls"`
46
+ TickRateMs int `json:"tickRateMs"`
47
+ CompletionMessage string `json:"completionMessage"`
48
+ Keybindings keybindings `json:"keybindings"`
49
+ }
50
+
51
+ type statePayload struct {
52
+ ConfigPath string `json:"configPath"`
53
+ Config config `json:"config"`
54
+ Fonts []string `json:"fonts"`
55
+ }
56
+
57
+ type menuEntry struct {
58
+ id string
59
+ title string
60
+ description string
61
+ }
62
+
63
+ func (m menuEntry) Title() string { return m.title }
64
+ func (m menuEntry) Description() string { return m.description }
65
+ func (m menuEntry) FilterValue() string { return m.title + " " + m.description }
66
+
67
+ type fontEntry struct {
68
+ name string
69
+ }
70
+
71
+ func (f fontEntry) Title() string { return f.name }
72
+ func (f fontEntry) Description() string { return "Press Enter to select" }
73
+ func (f fontEntry) FilterValue() string { return f.name }
74
+
75
+ type keyEntry struct {
76
+ token string
77
+ }
78
+
79
+ func (k keyEntry) Title() string { return keyTokenLabel(k.token) }
80
+ func (k keyEntry) Description() string { return "Press Enter to select" }
81
+ func (k keyEntry) FilterValue() string { return k.token + " " + keyTokenLabel(k.token) }
82
+
83
+ type screen int
84
+
85
+ const (
86
+ screenMain screen = iota
87
+ screenFontPicker
88
+ screenKeyPicker
89
+ screenTickRateEditor
90
+ screenMessageEditor
91
+ )
92
+
93
+ type model struct {
94
+ payload statePayload
95
+ menu list.Model
96
+ fontList list.Model
97
+ keyList list.Model
98
+ tickInput textinput.Model
99
+ messageInput textinput.Model
100
+ screen screen
101
+ keyTarget string
102
+ quitting bool
103
+ cancelled bool
104
+ err error
105
+ }
106
+
107
+ func boolText(v bool) string {
108
+ if v {
109
+ return "On"
110
+ }
111
+ return "Off"
112
+ }
113
+
114
+ func keyTokenLabel(token string) string {
115
+ if token == "space" {
116
+ return "Spacebar"
117
+ }
118
+ return token
119
+ }
120
+
121
+ func summarizeMessage(text string) string {
122
+ if strings.TrimSpace(text) == "" {
123
+ return "(empty)"
124
+ }
125
+ compact := strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ")
126
+ if len(compact) > 44 {
127
+ return compact[:41] + "..."
128
+ }
129
+ return compact
130
+ }
131
+
132
+ func supportedKeyTokens() []string {
133
+ tokens := []string{"space"}
134
+ for ch := 'a'; ch <= 'z'; ch++ {
135
+ tokens = append(tokens, string(ch))
136
+ }
137
+ for ch := '0'; ch <= '9'; ch++ {
138
+ tokens = append(tokens, string(ch))
139
+ }
140
+ tokens = append(tokens, "`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/")
141
+ return tokens
142
+ }
143
+
144
+ func buildMenuItems(cfg config) []list.Item {
145
+ return []list.Item{
146
+ menuEntry{id: "font", title: "Font", description: cfg.Font},
147
+ menuEntry{id: "center", title: "Center display", description: boolText(cfg.CenterDisplay)},
148
+ menuEntry{id: "header", title: "Show header", description: boolText(cfg.ShowHeader)},
149
+ menuEntry{id: "controls", title: "Show controls", description: boolText(cfg.ShowControls)},
150
+ menuEntry{id: "tickRate", title: "Tick rate", description: fmt.Sprintf("%d ms", cfg.TickRateMs)},
151
+ menuEntry{id: "message", title: "Completion message", description: summarizeMessage(cfg.CompletionMessage)},
152
+ menuEntry{id: "pauseKey", title: "Pause key", description: keyTokenLabel(cfg.Keybindings.PauseKey)},
153
+ menuEntry{id: "pauseAltKey", title: "Pause alt key", description: keyTokenLabel(cfg.Keybindings.PauseAltKey)},
154
+ menuEntry{id: "restartKey", title: "Restart key", description: keyTokenLabel(cfg.Keybindings.RestartKey)},
155
+ menuEntry{id: "exitKey", title: "Exit key", description: keyTokenLabel(cfg.Keybindings.ExitKey)},
156
+ menuEntry{id: "exitAltKey", title: "Exit alt key", description: keyTokenLabel(cfg.Keybindings.ExitAltKey)},
157
+ menuEntry{id: "save", title: "Save and exit", description: "Write settings and close"},
158
+ menuEntry{id: "cancel", title: "Cancel", description: "Discard changes"},
159
+ }
160
+ }
161
+
162
+ func buildFontItems(fonts []string) []list.Item {
163
+ items := make([]list.Item, 0, len(fonts))
164
+ for _, font := range fonts {
165
+ items = append(items, fontEntry{name: font})
166
+ }
167
+ return items
168
+ }
169
+
170
+ func buildKeyItems() []list.Item {
171
+ items := make([]list.Item, 0, len(supportedKeyTokens()))
172
+ for _, token := range supportedKeyTokens() {
173
+ items = append(items, keyEntry{token: token})
174
+ }
175
+ return items
176
+ }
177
+
178
+ func sanitizeTickRate(value int) int {
179
+ if value < minTickRateMs {
180
+ return minTickRateMs
181
+ }
182
+ if value > maxTickRateMs {
183
+ return maxTickRateMs
184
+ }
185
+ return value
186
+ }
187
+
188
+ func normalizeCompletionMessage(value string) string {
189
+ compact := strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " ")
190
+ if len(compact) > 240 {
191
+ return compact[:240]
192
+ }
193
+ return compact
194
+ }
195
+
196
+ func validKeyToken(value string) bool {
197
+ if value == "space" {
198
+ return true
199
+ }
200
+ if len(value) != 1 {
201
+ return false
202
+ }
203
+ ch := value[0]
204
+ return ch >= 33 && ch <= 126
205
+ }
206
+
207
+ func normalizeKeyToken(value, fallback string) string {
208
+ normalized := strings.ToLower(strings.TrimSpace(value))
209
+ if !validKeyToken(normalized) {
210
+ return fallback
211
+ }
212
+ return normalized
213
+ }
214
+
215
+ func normalizeKeybindings(cfg keybindings) keybindings {
216
+ result := defaultKeybindings
217
+ result.PauseKey = normalizeKeyToken(cfg.PauseKey, result.PauseKey)
218
+ result.PauseAltKey = normalizeKeyToken(cfg.PauseAltKey, result.PauseAltKey)
219
+ result.RestartKey = normalizeKeyToken(cfg.RestartKey, result.RestartKey)
220
+ result.ExitKey = normalizeKeyToken(cfg.ExitKey, result.ExitKey)
221
+ result.ExitAltKey = normalizeKeyToken(cfg.ExitAltKey, result.ExitAltKey)
222
+ return result
223
+ }
224
+
225
+ func normalizeConfig(cfg config) config {
226
+ result := config{
227
+ Font: defaultFont,
228
+ CenterDisplay: true,
229
+ ShowHeader: true,
230
+ ShowControls: true,
231
+ TickRateMs: defaultTickRateMs,
232
+ CompletionMessage: defaultCompletionMessage,
233
+ Keybindings: defaultKeybindings,
234
+ }
235
+
236
+ if strings.TrimSpace(cfg.Font) != "" {
237
+ result.Font = cfg.Font
238
+ }
239
+ result.CenterDisplay = cfg.CenterDisplay
240
+ result.ShowHeader = cfg.ShowHeader
241
+ result.ShowControls = cfg.ShowControls
242
+ if cfg.TickRateMs != 0 {
243
+ result.TickRateMs = sanitizeTickRate(cfg.TickRateMs)
244
+ }
245
+ if cfg.CompletionMessage != "" {
246
+ result.CompletionMessage = normalizeCompletionMessage(cfg.CompletionMessage)
247
+ }
248
+ result.Keybindings = normalizeKeybindings(cfg.Keybindings)
249
+ return result
250
+ }
251
+
252
+ func isConfirmKey(msg tea.KeyMsg) bool {
253
+ if msg.Type == tea.KeyEnter || msg.Type == tea.KeyCtrlM || msg.Type == tea.KeyCtrlJ {
254
+ return true
255
+ }
256
+ if len(msg.Runes) == 1 {
257
+ if msg.Runes[0] == '\r' || msg.Runes[0] == '\n' {
258
+ return true
259
+ }
260
+ }
261
+ s := msg.String()
262
+ return s == "enter" || s == "ctrl+m" || s == "ctrl+j" || s == "return"
263
+ }
264
+
265
+ func isBackKey(msg tea.KeyMsg) bool {
266
+ s := msg.String()
267
+ return s == "esc" || s == "q"
268
+ }
269
+
270
+ func isQuitKey(msg tea.KeyMsg) bool {
271
+ s := msg.String()
272
+ return s == "ctrl+c" || s == "q"
273
+ }
274
+
275
+ func isSaveKey(msg tea.KeyMsg) bool {
276
+ s := msg.String()
277
+ return s == "ctrl+s"
278
+ }
279
+
280
+ func newModel(payload statePayload) model {
281
+ menuModel := list.New(buildMenuItems(payload.Config), list.NewDefaultDelegate(), 0, 0)
282
+ menuModel.Title = "Timer Settings"
283
+ menuModel.SetShowHelp(true)
284
+ menuModel.SetFilteringEnabled(false)
285
+ menuModel.DisableQuitKeybindings()
286
+ menuModel.SetShowStatusBar(false)
287
+ menuModel.SetSize(100, 20)
288
+
289
+ fontModel := list.New(buildFontItems(payload.Fonts), list.NewDefaultDelegate(), 0, 0)
290
+ fontModel.Title = "Select Font"
291
+ fontModel.SetShowHelp(true)
292
+ fontModel.SetFilteringEnabled(true)
293
+ fontModel.DisableQuitKeybindings()
294
+ fontModel.SetSize(100, 20)
295
+
296
+ keyModel := list.New(buildKeyItems(), list.NewDefaultDelegate(), 0, 0)
297
+ keyModel.Title = "Select Key"
298
+ keyModel.SetShowHelp(true)
299
+ keyModel.SetFilteringEnabled(true)
300
+ keyModel.DisableQuitKeybindings()
301
+ keyModel.SetSize(100, 20)
302
+
303
+ tickInput := textinput.New()
304
+ tickInput.Prompt = "Tick rate (ms): "
305
+ tickInput.CharLimit = 4
306
+ tickInput.SetValue(strconv.Itoa(payload.Config.TickRateMs))
307
+ tickInput.Blur()
308
+
309
+ messageInput := textinput.New()
310
+ messageInput.Prompt = "Completion message: "
311
+ messageInput.CharLimit = 240
312
+ messageInput.SetValue(payload.Config.CompletionMessage)
313
+ messageInput.Blur()
314
+
315
+ return model{
316
+ payload: payload,
317
+ menu: menuModel,
318
+ fontList: fontModel,
319
+ keyList: keyModel,
320
+ tickInput: tickInput,
321
+ messageInput: messageInput,
322
+ screen: screenMain,
323
+ }
324
+ }
325
+
326
+ func (m model) Init() tea.Cmd {
327
+ return nil
328
+ }
329
+
330
+ func (m *model) refreshMenu() {
331
+ m.menu.SetItems(buildMenuItems(m.payload.Config))
332
+ }
333
+
334
+ func (m *model) save() error {
335
+ if m.payload.ConfigPath == "" {
336
+ return errors.New("config path is missing")
337
+ }
338
+ text, err := json.MarshalIndent(m.payload.Config, "", " ")
339
+ if err != nil {
340
+ return err
341
+ }
342
+ text = append(text, '\n')
343
+ return os.WriteFile(m.payload.ConfigPath, text, 0644)
344
+ }
345
+
346
+ func (m *model) selectFontItem(font string) {
347
+ for idx, item := range m.fontList.Items() {
348
+ entry, ok := item.(fontEntry)
349
+ if ok && entry.name == font {
350
+ m.fontList.Select(idx)
351
+ return
352
+ }
353
+ }
354
+ }
355
+
356
+ func (m *model) selectKeyItem(token string) {
357
+ for idx, item := range m.keyList.Items() {
358
+ entry, ok := item.(keyEntry)
359
+ if ok && entry.token == token {
360
+ m.keyList.Select(idx)
361
+ return
362
+ }
363
+ }
364
+ }
365
+
366
+ func (m *model) keyTokenForTarget(target string) string {
367
+ switch target {
368
+ case "pauseKey":
369
+ return m.payload.Config.Keybindings.PauseKey
370
+ case "pauseAltKey":
371
+ return m.payload.Config.Keybindings.PauseAltKey
372
+ case "restartKey":
373
+ return m.payload.Config.Keybindings.RestartKey
374
+ case "exitKey":
375
+ return m.payload.Config.Keybindings.ExitKey
376
+ case "exitAltKey":
377
+ return m.payload.Config.Keybindings.ExitAltKey
378
+ default:
379
+ return defaultKeybindings.PauseKey
380
+ }
381
+ }
382
+
383
+ func (m *model) setKeyTokenForTarget(target string, token string) {
384
+ switch target {
385
+ case "pauseKey":
386
+ m.payload.Config.Keybindings.PauseKey = token
387
+ case "pauseAltKey":
388
+ m.payload.Config.Keybindings.PauseAltKey = token
389
+ case "restartKey":
390
+ m.payload.Config.Keybindings.RestartKey = token
391
+ case "exitKey":
392
+ m.payload.Config.Keybindings.ExitKey = token
393
+ case "exitAltKey":
394
+ m.payload.Config.Keybindings.ExitAltKey = token
395
+ }
396
+ }
397
+
398
+ func (m *model) saveAndQuit() tea.Cmd {
399
+ if err := m.save(); err != nil {
400
+ m.err = err
401
+ return nil
402
+ }
403
+ m.quitting = true
404
+ return tea.Quit
405
+ }
406
+
407
+ func (m *model) openKeyPicker(target string, title string) {
408
+ m.keyTarget = target
409
+ m.keyList.Title = title
410
+ m.selectKeyItem(m.keyTokenForTarget(target))
411
+ m.screen = screenKeyPicker
412
+ }
413
+
414
+ func (m *model) applyMenuAction() tea.Cmd {
415
+ selected, ok := m.menu.SelectedItem().(menuEntry)
416
+ if !ok {
417
+ items := m.menu.Items()
418
+ index := m.menu.Index()
419
+ if index >= 0 && index < len(items) {
420
+ selected, ok = items[index].(menuEntry)
421
+ }
422
+ }
423
+ if !ok {
424
+ return nil
425
+ }
426
+ m.err = nil
427
+
428
+ switch selected.id {
429
+ case "font":
430
+ m.selectFontItem(m.payload.Config.Font)
431
+ m.screen = screenFontPicker
432
+ return nil
433
+ case "center":
434
+ m.payload.Config.CenterDisplay = !m.payload.Config.CenterDisplay
435
+ m.refreshMenu()
436
+ return nil
437
+ case "header":
438
+ m.payload.Config.ShowHeader = !m.payload.Config.ShowHeader
439
+ m.refreshMenu()
440
+ return nil
441
+ case "controls":
442
+ m.payload.Config.ShowControls = !m.payload.Config.ShowControls
443
+ m.refreshMenu()
444
+ return nil
445
+ case "tickRate":
446
+ m.tickInput.SetValue(strconv.Itoa(m.payload.Config.TickRateMs))
447
+ m.tickInput.CursorEnd()
448
+ m.tickInput.Focus()
449
+ m.screen = screenTickRateEditor
450
+ return nil
451
+ case "message":
452
+ m.messageInput.SetValue(m.payload.Config.CompletionMessage)
453
+ m.messageInput.CursorEnd()
454
+ m.messageInput.Focus()
455
+ m.screen = screenMessageEditor
456
+ return nil
457
+ case "pauseKey":
458
+ m.openKeyPicker("pauseKey", "Select Pause Key")
459
+ return nil
460
+ case "pauseAltKey":
461
+ m.openKeyPicker("pauseAltKey", "Select Pause Alt Key")
462
+ return nil
463
+ case "restartKey":
464
+ m.openKeyPicker("restartKey", "Select Restart Key")
465
+ return nil
466
+ case "exitKey":
467
+ m.openKeyPicker("exitKey", "Select Exit Key")
468
+ return nil
469
+ case "exitAltKey":
470
+ m.openKeyPicker("exitAltKey", "Select Exit Alt Key")
471
+ return nil
472
+ case "save":
473
+ return m.saveAndQuit()
474
+ case "cancel":
475
+ m.cancelled = true
476
+ m.quitting = true
477
+ return tea.Quit
478
+ default:
479
+ return nil
480
+ }
481
+ }
482
+
483
+ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
484
+ switch msg := msg.(type) {
485
+ case tea.WindowSizeMsg:
486
+ m.menu.SetSize(msg.Width, msg.Height-4)
487
+ m.fontList.SetSize(msg.Width, msg.Height-4)
488
+ m.keyList.SetSize(msg.Width, msg.Height-4)
489
+ if msg.Width > 26 {
490
+ m.tickInput.Width = msg.Width - 26
491
+ m.messageInput.Width = msg.Width - 26
492
+ }
493
+ return m, nil
494
+ case tea.KeyMsg:
495
+ switch m.screen {
496
+ case screenMain:
497
+ if isQuitKey(msg) {
498
+ m.cancelled = true
499
+ m.quitting = true
500
+ return m, tea.Quit
501
+ }
502
+ if isSaveKey(msg) {
503
+ return m, m.saveAndQuit()
504
+ }
505
+ if isConfirmKey(msg) {
506
+ cmd := m.applyMenuAction()
507
+ return m, cmd
508
+ }
509
+ case screenFontPicker:
510
+ if isBackKey(msg) {
511
+ m.screen = screenMain
512
+ return m, nil
513
+ }
514
+ if isConfirmKey(msg) {
515
+ item, ok := m.fontList.SelectedItem().(fontEntry)
516
+ if !ok {
517
+ items := m.fontList.Items()
518
+ index := m.fontList.Index()
519
+ if index >= 0 && index < len(items) {
520
+ item, ok = items[index].(fontEntry)
521
+ }
522
+ }
523
+ if ok {
524
+ m.payload.Config.Font = item.name
525
+ m.screen = screenMain
526
+ m.refreshMenu()
527
+ }
528
+ return m, nil
529
+ }
530
+ case screenKeyPicker:
531
+ if isBackKey(msg) {
532
+ m.screen = screenMain
533
+ return m, nil
534
+ }
535
+ if isConfirmKey(msg) {
536
+ item, ok := m.keyList.SelectedItem().(keyEntry)
537
+ if !ok {
538
+ items := m.keyList.Items()
539
+ index := m.keyList.Index()
540
+ if index >= 0 && index < len(items) {
541
+ item, ok = items[index].(keyEntry)
542
+ }
543
+ }
544
+ if ok {
545
+ m.setKeyTokenForTarget(m.keyTarget, item.token)
546
+ m.screen = screenMain
547
+ m.refreshMenu()
548
+ }
549
+ return m, nil
550
+ }
551
+ case screenTickRateEditor:
552
+ if isBackKey(msg) {
553
+ m.tickInput.Blur()
554
+ m.screen = screenMain
555
+ return m, nil
556
+ }
557
+ if isConfirmKey(msg) {
558
+ value, err := strconv.Atoi(strings.TrimSpace(m.tickInput.Value()))
559
+ if err != nil {
560
+ m.err = errors.New("tick rate must be an integer")
561
+ return m, nil
562
+ }
563
+ if value < minTickRateMs || value > maxTickRateMs {
564
+ m.err = fmt.Errorf("tick rate must be between %d and %d", minTickRateMs, maxTickRateMs)
565
+ return m, nil
566
+ }
567
+ m.payload.Config.TickRateMs = value
568
+ m.err = nil
569
+ m.tickInput.Blur()
570
+ m.screen = screenMain
571
+ m.refreshMenu()
572
+ return m, nil
573
+ }
574
+ case screenMessageEditor:
575
+ if isBackKey(msg) {
576
+ m.messageInput.Blur()
577
+ m.screen = screenMain
578
+ return m, nil
579
+ }
580
+ if isConfirmKey(msg) {
581
+ m.payload.Config.CompletionMessage = normalizeCompletionMessage(m.messageInput.Value())
582
+ m.err = nil
583
+ m.messageInput.Blur()
584
+ m.screen = screenMain
585
+ m.refreshMenu()
586
+ return m, nil
587
+ }
588
+ }
589
+ }
590
+
591
+ var cmd tea.Cmd
592
+ switch m.screen {
593
+ case screenMain:
594
+ m.menu, cmd = m.menu.Update(msg)
595
+ case screenFontPicker:
596
+ m.fontList, cmd = m.fontList.Update(msg)
597
+ case screenKeyPicker:
598
+ m.keyList, cmd = m.keyList.Update(msg)
599
+ case screenTickRateEditor:
600
+ m.tickInput, cmd = m.tickInput.Update(msg)
601
+ case screenMessageEditor:
602
+ m.messageInput, cmd = m.messageInput.Update(msg)
603
+ }
604
+
605
+ return m, cmd
606
+ }
607
+
608
+ func (m model) View() string {
609
+ if m.quitting {
610
+ if m.err != nil {
611
+ return fmt.Sprintf("Error: %v\n", m.err)
612
+ }
613
+ if m.cancelled {
614
+ return "Cancelled\n"
615
+ }
616
+ return "Saved\n"
617
+ }
618
+
619
+ errorLine := ""
620
+ if m.err != nil {
621
+ errorLine = fmt.Sprintf("\nError: %v\n", m.err)
622
+ }
623
+
624
+ switch m.screen {
625
+ case screenMain:
626
+ return m.menu.View() + errorLine + "\nEnter: select/edit | Ctrl+S: save and exit | q: cancel | Ctrl+C: cancel"
627
+ case screenFontPicker:
628
+ return m.fontList.View() + "\nEnter: choose font | /: filter | esc: back"
629
+ case screenKeyPicker:
630
+ return m.keyList.View() + "\nEnter: choose key | /: filter | esc: back"
631
+ case screenTickRateEditor:
632
+ return fmt.Sprintf("Tick rate (%d-%d ms)\n\n%s%s\n\nEnter: save | esc: back", minTickRateMs, maxTickRateMs, m.tickInput.View(), errorLine)
633
+ case screenMessageEditor:
634
+ return fmt.Sprintf("Completion message\n\n%s%s\n\nEnter: save | esc: back", m.messageInput.View(), errorLine)
635
+ default:
636
+ return ""
637
+ }
638
+ }
639
+
640
+ func containsString(values []string, needle string) bool {
641
+ for _, value := range values {
642
+ if value == needle {
643
+ return true
644
+ }
645
+ }
646
+ return false
647
+ }
648
+
649
+ func loadPayload(statePath string) (statePayload, error) {
650
+ if statePath == "" {
651
+ return statePayload{}, errors.New("--state is required")
652
+ }
653
+ text, err := os.ReadFile(statePath)
654
+ if err != nil {
655
+ return statePayload{}, err
656
+ }
657
+ var payload statePayload
658
+ if err := json.Unmarshal(text, &payload); err != nil {
659
+ return statePayload{}, err
660
+ }
661
+ if strings.TrimSpace(payload.ConfigPath) == "" {
662
+ return statePayload{}, errors.New("configPath is missing in state payload")
663
+ }
664
+ if len(payload.Fonts) == 0 {
665
+ payload.Fonts = []string{defaultFont}
666
+ }
667
+ payload.Config = normalizeConfig(payload.Config)
668
+ if !containsString(payload.Fonts, payload.Config.Font) {
669
+ payload.Config.Font = payload.Fonts[0]
670
+ }
671
+ return payload, nil
672
+ }
673
+
674
+ func main() {
675
+ statePath := flag.String("state", "", "Path to JSON state file")
676
+ flag.Parse()
677
+
678
+ payload, err := loadPayload(*statePath)
679
+ if err != nil {
680
+ fmt.Fprintf(os.Stderr, "Failed to load state: %v\n", err)
681
+ os.Exit(1)
682
+ }
683
+
684
+ p := tea.NewProgram(newModel(payload), tea.WithAltScreen())
685
+ finalModel, err := p.Run()
686
+ if err != nil {
687
+ fmt.Fprintf(os.Stderr, "Settings UI failed: %v\n", err)
688
+ os.Exit(1)
689
+ }
690
+
691
+ m := finalModel.(model)
692
+ if m.err != nil {
693
+ fmt.Fprintf(os.Stderr, "Failed to save settings: %v\n", m.err)
694
+ os.Exit(1)
695
+ }
696
+ if m.cancelled {
697
+ os.Exit(2)
698
+ }
699
+ }