Files
woke/ui/config.go

332 lines
8.3 KiB
Go

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 <name>",
}
// 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
}