Lucky manta ray
This commit is contained in:
65
db/db.go
65
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
211
ui/config.go
Normal file
211
ui/config.go
Normal 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
|
||||
}
|
||||
103
ui/model.go
103
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 {
|
||||
|
||||
Reference in New Issue
Block a user