package ui import ( "fmt" "strconv" "strings" "woke/db" tea "github.com/charmbracelet/bubbletea" ) type cfgField int const ( cfgSnoozeMinutes cfgField = iota cfgTimeoutMinutes cfgDefaultSound cfgBlinkOnMs cfgBlinkOffMs cfgColorClock cfgColorAlarm cfgShowSeconds cfgServerURL cfgServerPassword cfgAPIPassword cfgPollSeconds cfgFieldCount ) type configModel struct { fields [cfgFieldCount]string active cfgField err string } func newConfigModel(cfg db.Settings) *configModel { c := &configModel{} c.fields[cfgSnoozeMinutes] = strconv.Itoa(cfg.SnoozeMinutes) c.fields[cfgTimeoutMinutes] = strconv.Itoa(cfg.TimeoutMinutes) c.fields[cfgDefaultSound] = cfg.DefaultSound c.fields[cfgBlinkOnMs] = strconv.Itoa(cfg.BlinkOnMs) c.fields[cfgBlinkOffMs] = strconv.Itoa(cfg.BlinkOffMs) c.fields[cfgColorClock] = cfg.ColorClock c.fields[cfgColorAlarm] = cfg.ColorAlarm if cfg.ShowSeconds { c.fields[cfgShowSeconds] = "true" } else { c.fields[cfgShowSeconds] = "false" } c.fields[cfgServerURL] = cfg.ServerURL c.fields[cfgServerPassword] = cfg.ServerPassword c.fields[cfgAPIPassword] = cfg.APIPassword c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds) return c } func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) { key := msg.String() switch key { case "esc", "escape": m.mode = viewMain m.config = nil m.statusMsg = "" return *m, nil case "tab", "down": c.active = (c.active + 1) % cfgFieldCount return *m, nil case "shift+tab", "up": c.active = (c.active - 1 + cfgFieldCount) % cfgFieldCount return *m, nil case "enter": return c.save(m) case "backspace": field := &c.fields[c.active] if len(*field) > 0 { *field = (*field)[:len(*field)-1] } c.err = "" return *m, nil case "ctrl+u": c.fields[c.active] = "" c.err = "" return *m, nil case " ": // Toggle boolean fields if c.active == cfgShowSeconds { if c.fields[cfgShowSeconds] == "true" { c.fields[cfgShowSeconds] = "false" } else { c.fields[cfgShowSeconds] = "true" } return *m, nil } // Space as literal for other fields c.fields[c.active] += " " c.err = "" return *m, nil default: if len(key) == 1 { // Don't allow free typing on boolean fields if c.active == cfgShowSeconds { return *m, nil } c.fields[c.active] += key c.err = "" } return *m, nil } } func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) { snooze, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgSnoozeMinutes])) if err != nil || snooze < 1 || snooze > 60 { c.err = "Snooze must be 1-60 minutes" return *m, nil } timeout, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgTimeoutMinutes])) if err != nil || timeout < 1 || timeout > 60 { c.err = "Timeout must be 1-60 minutes" return *m, nil } blinkOn, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgBlinkOnMs])) if err != nil || blinkOn < 100 || blinkOn > 5000 { c.err = "Blink on must be 100-5000 ms" return *m, nil } blinkOff, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgBlinkOffMs])) if err != nil || blinkOff < 100 || blinkOff > 5000 { c.err = "Blink off must be 100-5000 ms" return *m, nil } colorClock := strings.TrimSpace(c.fields[cfgColorClock]) if !isValidHexColor(colorClock) { c.err = "Clock color must be hex like #00FF88" return *m, nil } colorAlarm := strings.TrimSpace(c.fields[cfgColorAlarm]) if !isValidHexColor(colorAlarm) { c.err = "Alarm color must be hex like #FF4444" return *m, nil } pollSeconds, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgPollSeconds])) if err != nil || pollSeconds < 1 || pollSeconds > 60 { c.err = "Poll seconds must be 1-60" return *m, nil } cfg := db.Settings{ SnoozeMinutes: snooze, TimeoutMinutes: timeout, DefaultSound: strings.TrimSpace(c.fields[cfgDefaultSound]), BlinkOnMs: blinkOn, BlinkOffMs: blinkOff, ColorClock: colorClock, ColorAlarm: colorAlarm, } showSec := strings.TrimSpace(c.fields[cfgShowSeconds]) cfg.ShowSeconds = showSec == "true" cfg.ServerURL = strings.TrimSpace(c.fields[cfgServerURL]) cfg.ServerPassword = strings.TrimSpace(c.fields[cfgServerPassword]) cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword]) cfg.PollSeconds = pollSeconds if cfg.DefaultSound == "" { cfg.DefaultSound = "default" } if err := m.store.SaveSettings(cfg); err != nil { c.err = fmt.Sprintf("Save failed: %v", err) return *m, nil } m.settings = cfg m.applySettings() m.mode = viewMain m.config = nil m.statusMsg = "Settings saved" return *m, nil } func (c *configModel) View() string { labels := [cfgFieldCount]string{ "Snooze (min):", "Timeout (min):", "Default sound:", "Blink on (ms):", "Blink off (ms):", "Clock color:", "Alarm color:", "Show seconds:", "Server URL:", "Server pass:", "API password:", "Poll (sec):", } hints := [cfgFieldCount]string{ "1-60", "1-60, auto-dismiss firing alarm", "'default' or path to sound file", "100-5000", "100-5000", "hex e.g. #00FF88", "hex e.g. #FF4444", "space to toggle", "empty=local, or http://host:9119", "password to auth with server", "password for incoming API requests", "1-60, client poll frequency", } var lines []string lines = append(lines, TitleStyle.Render("Settings"), "") for i := cfgField(0); i < cfgFieldCount; i++ { labelStr := fmt.Sprintf("%15s", labels[i]) value := c.fields[i] var style = FormLabelStyle if i == c.active { style = FormActiveStyle value += "█" } if len(value) > formValueWidth { value = value[len(value)-formValueWidth:] } valueStr := fmt.Sprintf("%-*s", formValueWidth, value) hintStr := fmt.Sprintf("%-*s", formHintWidth, hints[i]) line := FormLabelStyle.Render(labelStr) + " " + style.Render(valueStr) + " " + HelpStyle.Render(hintStr) lines = append(lines, line) } if c.err != "" { lines = append(lines, "", FormErrorStyle.Render(" Error: "+c.err)) } return strings.Join(lines, "\n") } func isValidHexColor(s string) bool { if len(s) != 7 || s[0] != '#' { return false } for _, c := range s[1:] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true }