332 lines
8.3 KiB
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
|
|
}
|