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 cfgCrescendoEnabled cfgCrescendoStartPct cfgCrescendoEndPct cfgCrescendoDurationS cfgCallbackScript 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) if cfg.CrescendoEnabled { c.fields[cfgCrescendoEnabled] = "true" } else { c.fields[cfgCrescendoEnabled] = "false" } c.fields[cfgCrescendoStartPct] = strconv.Itoa(cfg.CrescendoStartPct) c.fields[cfgCrescendoEndPct] = strconv.Itoa(cfg.CrescendoEndPct) c.fields[cfgCrescendoDurationS] = strconv.Itoa(cfg.CrescendoDurationS) c.fields[cfgCallbackScript] = cfg.CallbackScript 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 || c.active == cfgCrescendoEnabled { field := &c.fields[c.active] if *field == "true" { *field = "false" } else { *field = "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 || c.active == cfgCrescendoEnabled { 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 } crescStartPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoStartPct])) if err != nil || crescStartPct < 0 || crescStartPct > 100 { c.err = "Crescendo start must be 0-100%" return *m, nil } crescEndPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoEndPct])) if err != nil || crescEndPct < 0 || crescEndPct > 150 { c.err = "Crescendo end must be 0-150%" return *m, nil } crescDuration, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoDurationS])) if err != nil || crescDuration < 1 || crescDuration > 300 { c.err = "Crescendo duration must be 1-300 seconds" 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 cfg.CrescendoEnabled = strings.TrimSpace(c.fields[cfgCrescendoEnabled]) == "true" cfg.CrescendoStartPct = crescStartPct cfg.CrescendoEndPct = crescEndPct cfg.CrescendoDurationS = crescDuration cfg.CallbackScript = strings.TrimSpace(c.fields[cfgCallbackScript]) 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):", "Enabled:", "Start %:", "End %:", "Duration (s):", "Script path:", } 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", "space to toggle", "0-100, starting volume", "0-150, ending volume (>100 = overdrive)", "1-300, ramp duration in seconds", "called with: start|dismiss|snooze|timeout ", } // Section headers: field index -> section name sections := map[cfgField]string{ cfgSnoozeMinutes: "─── Alarm ───", cfgBlinkOnMs: "─── Display ───", cfgServerURL: "─── Network ───", cfgCrescendoEnabled: "─── Crescendo (pactl) ───", cfgCallbackScript: "─── Hooks ───", } var lines []string lines = append(lines, TitleStyle.Render("Settings"), "") for i := cfgField(0); i < cfgFieldCount; i++ { // Insert section header if this field starts a new section if section, ok := sections[i]; ok { lines = append(lines, "", DividerStyle.Render(section)) } 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 }