diff --git a/db/db.go b/db/db.go index bd19b4f..e818f7e 100644 --- a/db/db.go +++ b/db/db.go @@ -191,6 +191,71 @@ func (s *Store) MarkTriggered(id int) error { return err } +// Settings holds all configurable app settings. +type Settings struct { + SnoozeMinutes int + TimeoutMinutes int + DefaultSound string + BlinkOnMs int + BlinkOffMs int + ColorClock string + ColorAlarm string +} + +func DefaultSettings() Settings { + return Settings{ + SnoozeMinutes: 5, + TimeoutMinutes: 5, + DefaultSound: "default", + BlinkOnMs: 1000, + BlinkOffMs: 500, + ColorClock: "#00FF88", + ColorAlarm: "#FF4444", + } +} + +func (s *Store) LoadSettings() Settings { + cfg := DefaultSettings() + cfg.SnoozeMinutes = s.getSettingInt("snooze_minutes", cfg.SnoozeMinutes) + cfg.TimeoutMinutes = s.getSettingInt("timeout_minutes", cfg.TimeoutMinutes) + cfg.DefaultSound = s.GetSetting("default_sound", cfg.DefaultSound) + cfg.BlinkOnMs = s.getSettingInt("blink_on_ms", cfg.BlinkOnMs) + cfg.BlinkOffMs = s.getSettingInt("blink_off_ms", cfg.BlinkOffMs) + cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock) + cfg.ColorAlarm = s.GetSetting("color_alarm", cfg.ColorAlarm) + return cfg +} + +func (s *Store) SaveSettings(cfg Settings) error { + pairs := map[string]string{ + "snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes), + "timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes), + "default_sound": cfg.DefaultSound, + "blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs), + "blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs), + "color_clock": cfg.ColorClock, + "color_alarm": cfg.ColorAlarm, + } + for k, v := range pairs { + if err := s.SetSetting(k, v); err != nil { + return err + } + } + return nil +} + +func (s *Store) getSettingInt(key string, defaultVal int) int { + val := s.GetSetting(key, "") + if val == "" { + return defaultVal + } + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err != nil { + return defaultVal + } + return n +} + func (s *Store) GetSetting(key, defaultVal string) string { var val string err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val) diff --git a/ui/clock.go b/ui/clock.go index 2ca4ab3..72ba94a 100644 --- a/ui/clock.go +++ b/ui/clock.go @@ -131,17 +131,16 @@ var bigColonBlink = []string{ } // RenderBigClock renders the current time as massive ASCII block digits. -// Format: HH:MM:SS in 24h. The colon blinks on a 500ms cycle (offset from -// the second boundary so it doesn't flip in sync with the digits). -func RenderBigClock(t time.Time) string { +// Format: HH:MM:SS in 24h. blinkOnMs/blinkOffMs control the colon blink cycle. +func RenderBigClock(t time.Time, blinkOnMs, blinkOffMs int) string { h := t.Hour() m := t.Minute() s := t.Second() timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s) - // 1500ms cycle: visible for 1000ms, hidden for 500ms. - colonVisible := t.UnixMilli() % 1500 < 1000 + cycle := int64(blinkOnMs + blinkOffMs) + colonVisible := t.UnixMilli()%cycle < int64(blinkOnMs) var lines [7]string for i := range lines { diff --git a/ui/config.go b/ui/config.go new file mode 100644 index 0000000..606e7b9 --- /dev/null +++ b/ui/config.go @@ -0,0 +1,211 @@ +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 + 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 + 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 + + default: + if len(key) == 1 { + 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 + } + + cfg := db.Settings{ + SnoozeMinutes: snooze, + TimeoutMinutes: timeout, + DefaultSound: strings.TrimSpace(c.fields[cfgDefaultSound]), + BlinkOnMs: blinkOn, + BlinkOffMs: blinkOff, + ColorClock: colorClock, + ColorAlarm: colorAlarm, + } + + 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:", + } + + 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", + } + + 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 +} diff --git a/ui/model.go b/ui/model.go index 02c34d1..b71ceb2 100644 --- a/ui/model.go +++ b/ui/model.go @@ -19,6 +19,7 @@ const ( viewMain viewMode = iota viewForm viewConfirmDelete + viewConfig ) // Messages @@ -53,17 +54,55 @@ type Model struct { // Form state form *formModel + // Config state + config *configModel + settings db.Settings + // Error/status statusMsg string } func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { - return Model{ + m := Model{ store: store, scheduler: sched, player: pl, now: time.Now(), + settings: store.LoadSettings(), } + m.applySettings() + return m +} + +// applySettings updates runtime styles and durations from current settings. +func (m *Model) applySettings() { + ClockStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorClock)). + Bold(true) + + ClockAlarmStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorAlarm)). + Bold(true) + + AlarmEnabledStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorClock)) + + TitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorClock)). + Bold(true). + Underline(true) + + FormActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorClock)). + Bold(true) + + AlarmFiringStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorAlarm)). + Bold(true). + Blink(true) + + FormErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.settings.ColorAlarm)) } func (m Model) Init() tea.Cmd { @@ -123,7 +162,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshAlarms() return m, tea.Batch( listenForAlarms(m.scheduler), - autoTimeoutCmd(), + autoTimeoutCmd(m.autoTimeoutDuration()), ) case snoozeFireMsg: @@ -132,12 +171,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.snoozeUntil = time.Time{} m.snoozeName = "" m.startFiring(&alarm) - return m, autoTimeoutCmd() + return m, autoTimeoutCmd(m.autoTimeoutDuration()) case autoTimeoutMsg: if m.firingAlarm != nil { m.player.Stop() - m.statusMsg = "Alarm auto-dismissed after 5 minutes" + m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes) m.firingAlarm = nil m.snoozeCount = 0 } @@ -175,11 +214,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": m.player.Stop() alarm := *m.firingAlarm + dur := m.snoozeDuration() m.firingAlarm = nil - m.snoozeUntil = time.Now().Add(snoozeDuration) + m.snoozeUntil = time.Now().Add(dur) m.snoozeName = alarm.Name - m.statusMsg = fmt.Sprintf("Snoozed '%s' for 5 minutes", alarm.Name) - return m, snoozeCmd(alarm, snoozeDuration) + m.statusMsg = fmt.Sprintf("Snoozed '%s' for %d minutes", alarm.Name, m.settings.SnoozeMinutes) + return m, snoozeCmd(alarm, dur) } return m, nil } @@ -189,6 +229,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.form.HandleKey(msg, &m) } + // Config mode + if m.mode == viewConfig && m.config != nil { + return m.config.HandleKey(msg, &m) + } + // Confirm delete mode if m.mode == viewConfirmDelete { switch key { @@ -249,18 +294,31 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = viewConfirmDelete m.statusMsg = fmt.Sprintf("Delete '%s'? (y/n)", m.alarms[m.cursor].Name) } + case "c": + m.config = newConfigModel(m.settings) + m.mode = viewConfig + m.statusMsg = "" } return m, nil } -const snoozeDuration = 5 * time.Minute -const autoTimeoutDuration = 5 * time.Minute +func (m *Model) snoozeDuration() time.Duration { + return time.Duration(m.settings.SnoozeMinutes) * time.Minute +} + +func (m *Model) autoTimeoutDuration() time.Duration { + return time.Duration(m.settings.TimeoutMinutes) * time.Minute +} func (m *Model) startFiring(alarm *db.Alarm) { m.firingAlarm = alarm m.firingBlink = true m.firingStart = time.Now() - m.player.PlayLoop(alarm.SoundPath) + sound := alarm.SoundPath + if sound == "default" || sound == "" { + sound = m.settings.DefaultSound + } + m.player.PlayLoop(sound) } func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd { @@ -270,9 +328,9 @@ func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd { } } -func autoTimeoutCmd() tea.Cmd { +func autoTimeoutCmd(dur time.Duration) tea.Cmd { return func() tea.Msg { - time.Sleep(autoTimeoutDuration) + time.Sleep(dur) return autoTimeoutMsg{} } } @@ -292,7 +350,7 @@ func (m Model) View() string { var sections []string // Big clock - clockStr := RenderBigClock(m.now) + clockStr := RenderBigClock(m.now, m.settings.BlinkOnMs, m.settings.BlinkOffMs) if m.firingAlarm != nil && m.firingBlink { clockStr = ClockAlarmStyle.Render(clockStr) } else { @@ -326,7 +384,7 @@ func (m Model) View() string { if m.snoozeCount > 0 { firingText += fmt.Sprintf("\n(snoozed %d time(s))", m.snoozeCount) } - firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze 5min" + firingText += fmt.Sprintf("\n\n[Enter/Space/d] Dismiss [s] Snooze %dmin", m.settings.SnoozeMinutes) styled := AlarmFiringStyle.Render(firingText) styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled) sections = append(sections, "", styled) @@ -337,8 +395,12 @@ func (m Model) View() string { divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider) sections = append(sections, "", divider, "") - // Form view - if m.mode == viewForm && m.form != nil { + // Form / Config view + if m.mode == viewConfig && m.config != nil { + configView := m.config.View() + configView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, configView) + sections = append(sections, configView) + } else if m.mode == viewForm && m.form != nil { formView := m.form.View() formView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, formView) sections = append(sections, formView) @@ -438,13 +500,14 @@ func (m Model) renderAlarmList() string { } func (m Model) renderHelp() string { - if m.mode == viewForm { + switch m.mode { + case viewForm, viewConfig: return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel") - } - if m.mode == viewConfirmDelete { + case viewConfirmDelete: return HelpStyle.Render("y: confirm delete n: cancel") + default: + return HelpStyle.Render("j/k: navigate a: add e: edit d: delete space: toggle c: config q: quit") } - return HelpStyle.Render("j/k: navigate a: add e: edit d: delete space: toggle q: quit") } func min(a, b int) int { diff --git a/woke b/woke index a096605..4b9dade 100755 Binary files a/woke and b/woke differ