Lucky manta ray

This commit is contained in:
2026-02-02 19:38:02 +02:00
parent c24ef1cc2f
commit d1307a75b9
5 changed files with 363 additions and 25 deletions

View File

@@ -191,6 +191,71 @@ func (s *Store) MarkTriggered(id int) error {
return err 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 { func (s *Store) GetSetting(key, defaultVal string) string {
var val string var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val) err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val)

View File

@@ -131,17 +131,16 @@ var bigColonBlink = []string{
} }
// RenderBigClock renders the current time as massive ASCII block digits. // 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 // Format: HH:MM:SS in 24h. blinkOnMs/blinkOffMs control the colon blink cycle.
// the second boundary so it doesn't flip in sync with the digits). func RenderBigClock(t time.Time, blinkOnMs, blinkOffMs int) string {
func RenderBigClock(t time.Time) string {
h := t.Hour() h := t.Hour()
m := t.Minute() m := t.Minute()
s := t.Second() s := t.Second()
timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s) timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s)
// 1500ms cycle: visible for 1000ms, hidden for 500ms. cycle := int64(blinkOnMs + blinkOffMs)
colonVisible := t.UnixMilli() % 1500 < 1000 colonVisible := t.UnixMilli()%cycle < int64(blinkOnMs)
var lines [7]string var lines [7]string
for i := range lines { for i := range lines {

211
ui/config.go Normal file
View File

@@ -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
}

View File

@@ -19,6 +19,7 @@ const (
viewMain viewMode = iota viewMain viewMode = iota
viewForm viewForm
viewConfirmDelete viewConfirmDelete
viewConfig
) )
// Messages // Messages
@@ -53,17 +54,55 @@ type Model struct {
// Form state // Form state
form *formModel form *formModel
// Config state
config *configModel
settings db.Settings
// Error/status // Error/status
statusMsg string statusMsg string
} }
func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model {
return Model{ m := Model{
store: store, store: store,
scheduler: sched, scheduler: sched,
player: pl, player: pl,
now: time.Now(), 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 { func (m Model) Init() tea.Cmd {
@@ -123,7 +162,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshAlarms() m.refreshAlarms()
return m, tea.Batch( return m, tea.Batch(
listenForAlarms(m.scheduler), listenForAlarms(m.scheduler),
autoTimeoutCmd(), autoTimeoutCmd(m.autoTimeoutDuration()),
) )
case snoozeFireMsg: case snoozeFireMsg:
@@ -132,12 +171,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.snoozeUntil = time.Time{} m.snoozeUntil = time.Time{}
m.snoozeName = "" m.snoozeName = ""
m.startFiring(&alarm) m.startFiring(&alarm)
return m, autoTimeoutCmd() return m, autoTimeoutCmd(m.autoTimeoutDuration())
case autoTimeoutMsg: case autoTimeoutMsg:
if m.firingAlarm != nil { if m.firingAlarm != nil {
m.player.Stop() 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.firingAlarm = nil
m.snoozeCount = 0 m.snoozeCount = 0
} }
@@ -175,11 +214,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "s": case "s":
m.player.Stop() m.player.Stop()
alarm := *m.firingAlarm alarm := *m.firingAlarm
dur := m.snoozeDuration()
m.firingAlarm = nil m.firingAlarm = nil
m.snoozeUntil = time.Now().Add(snoozeDuration) m.snoozeUntil = time.Now().Add(dur)
m.snoozeName = alarm.Name m.snoozeName = alarm.Name
m.statusMsg = fmt.Sprintf("Snoozed '%s' for 5 minutes", alarm.Name) m.statusMsg = fmt.Sprintf("Snoozed '%s' for %d minutes", alarm.Name, m.settings.SnoozeMinutes)
return m, snoozeCmd(alarm, snoozeDuration) return m, snoozeCmd(alarm, dur)
} }
return m, nil return m, nil
} }
@@ -189,6 +229,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.form.HandleKey(msg, &m) return m.form.HandleKey(msg, &m)
} }
// Config mode
if m.mode == viewConfig && m.config != nil {
return m.config.HandleKey(msg, &m)
}
// Confirm delete mode // Confirm delete mode
if m.mode == viewConfirmDelete { if m.mode == viewConfirmDelete {
switch key { switch key {
@@ -249,18 +294,31 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = viewConfirmDelete m.mode = viewConfirmDelete
m.statusMsg = fmt.Sprintf("Delete '%s'? (y/n)", m.alarms[m.cursor].Name) 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 return m, nil
} }
const snoozeDuration = 5 * time.Minute func (m *Model) snoozeDuration() time.Duration {
const autoTimeoutDuration = 5 * time.Minute 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) { func (m *Model) startFiring(alarm *db.Alarm) {
m.firingAlarm = alarm m.firingAlarm = alarm
m.firingBlink = true m.firingBlink = true
m.firingStart = time.Now() 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 { 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 { return func() tea.Msg {
time.Sleep(autoTimeoutDuration) time.Sleep(dur)
return autoTimeoutMsg{} return autoTimeoutMsg{}
} }
} }
@@ -292,7 +350,7 @@ func (m Model) View() string {
var sections []string var sections []string
// Big clock // Big clock
clockStr := RenderBigClock(m.now) clockStr := RenderBigClock(m.now, m.settings.BlinkOnMs, m.settings.BlinkOffMs)
if m.firingAlarm != nil && m.firingBlink { if m.firingAlarm != nil && m.firingBlink {
clockStr = ClockAlarmStyle.Render(clockStr) clockStr = ClockAlarmStyle.Render(clockStr)
} else { } else {
@@ -326,7 +384,7 @@ func (m Model) View() string {
if m.snoozeCount > 0 { if m.snoozeCount > 0 {
firingText += fmt.Sprintf("\n(snoozed %d time(s))", m.snoozeCount) 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 := AlarmFiringStyle.Render(firingText)
styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled) styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled)
sections = append(sections, "", styled) sections = append(sections, "", styled)
@@ -337,8 +395,12 @@ func (m Model) View() string {
divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider) divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider)
sections = append(sections, "", divider, "") sections = append(sections, "", divider, "")
// Form view // Form / Config view
if m.mode == viewForm && m.form != nil { 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 := m.form.View()
formView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, formView) formView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, formView)
sections = append(sections, formView) sections = append(sections, formView)
@@ -438,13 +500,14 @@ func (m Model) renderAlarmList() string {
} }
func (m Model) renderHelp() 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") return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel")
} case viewConfirmDelete:
if m.mode == viewConfirmDelete {
return HelpStyle.Render("y: confirm delete n: cancel") 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 { func min(a, b int) int {

BIN
woke

Binary file not shown.