@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.
- package/README.md +117 -0
- package/bin/stopwatch.js +5 -0
- package/bin/timer.js +5 -0
- package/package.json +24 -0
- package/settings-ui/go.mod +28 -0
- package/settings-ui/go.sum +57 -0
- package/settings-ui/main.go +699 -0
- package/settings-ui/main_test.go +86 -0
- package/src/index.js +791 -0
|
@@ -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
|
+
}
|