Files
woke/ui/model.go
2026-02-02 19:38:02 +02:00

519 lines
12 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
"woke/db"
"woke/player"
"woke/scheduler"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// View modes
type viewMode int
const (
viewMain viewMode = iota
viewForm
viewConfirmDelete
viewConfig
)
// Messages
type tickMsg time.Time
type alarmFiredMsg scheduler.AlarmEvent
type alarmsLoadedMsg []db.Alarm
type snoozeFireMsg db.Alarm
type autoTimeoutMsg struct{}
// Model is the main bubbletea model.
type Model struct {
store *db.Store
scheduler *scheduler.Scheduler
player *player.Player
// State
alarms []db.Alarm
cursor int
mode viewMode
width int
height int
now time.Time
// Alarm firing state
firingAlarm *db.Alarm
firingBlink bool
firingStart time.Time
snoozeCount int
snoozeUntil time.Time
snoozeName string
// 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 {
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 {
return tea.Batch(
tick(),
listenForAlarms(m.scheduler),
loadAlarmsCmd(m.store),
)
}
func tick() tea.Cmd {
return tea.Every(100*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd {
return func() tea.Msg {
event := <-sched.Events()
return alarmFiredMsg(event)
}
}
func loadAlarmsCmd(store *db.Store) tea.Cmd {
return func() tea.Msg {
alarms, err := store.ListAlarms()
if err != nil {
return alarmsLoadedMsg(nil)
}
return alarmsLoadedMsg(alarms)
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case alarmsLoadedMsg:
if msg != nil {
m.alarms = []db.Alarm(msg)
}
return m, nil
case tickMsg:
m.now = time.Time(msg)
if m.firingAlarm != nil {
m.firingBlink = !m.firingBlink
}
return m, tick()
case alarmFiredMsg:
alarm := msg.Alarm
m.startFiring(&alarm)
m.refreshAlarms()
return m, tea.Batch(
listenForAlarms(m.scheduler),
autoTimeoutCmd(m.autoTimeoutDuration()),
)
case snoozeFireMsg:
alarm := db.Alarm(msg)
m.snoozeCount++
m.snoozeUntil = time.Time{}
m.snoozeName = ""
m.startFiring(&alarm)
return m, autoTimeoutCmd(m.autoTimeoutDuration())
case autoTimeoutMsg:
if m.firingAlarm != nil {
m.player.Stop()
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
m.firingAlarm = nil
m.snoozeCount = 0
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
// Forward to form if active
if m.mode == viewForm && m.form != nil {
return m.form.Update(msg, &m)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// Global: quit
if key == "ctrl+c" {
return m, tea.Quit
}
// If alarm is firing, handle dismiss/snooze
if m.firingAlarm != nil {
switch key {
case "enter", " ", "d":
m.player.Stop()
alarm := m.firingAlarm
m.firingAlarm = nil
m.snoozeCount = 0
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
case "s":
m.player.Stop()
alarm := *m.firingAlarm
dur := m.snoozeDuration()
m.firingAlarm = nil
m.snoozeUntil = time.Now().Add(dur)
m.snoozeName = alarm.Name
m.statusMsg = fmt.Sprintf("Snoozed '%s' for %d minutes", alarm.Name, m.settings.SnoozeMinutes)
return m, snoozeCmd(alarm, dur)
}
return m, nil
}
// Form mode
if m.mode == viewForm && m.form != nil {
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 {
case "y", "Y":
if m.cursor < len(m.alarms) {
_ = m.store.DeleteAlarm(m.alarms[m.cursor].ID)
m.refreshAlarms()
if m.cursor >= len(m.alarms) && m.cursor > 0 {
m.cursor--
}
m.statusMsg = "Alarm deleted"
}
m.mode = viewMain
case "n", "N", "escape", "esc":
m.mode = viewMain
m.statusMsg = ""
}
return m, nil
}
// Main view keys
switch key {
case "q":
return m, tea.Quit
case "j", "down":
if m.cursor < len(m.alarms)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case "g":
m.cursor = 0
case "G":
if len(m.alarms) > 0 {
m.cursor = len(m.alarms) - 1
}
case " ":
// Toggle enabled
if m.cursor < len(m.alarms) {
_ = m.store.ToggleAlarm(m.alarms[m.cursor].ID)
m.refreshAlarms()
}
case "a":
m.form = newFormModel(nil)
m.mode = viewForm
m.statusMsg = ""
case "e":
if m.cursor < len(m.alarms) {
alarm := m.alarms[m.cursor]
m.form = newFormModel(&alarm)
m.mode = viewForm
m.statusMsg = ""
}
case "d":
if m.cursor < len(m.alarms) {
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
}
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()
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 {
return func() tea.Msg {
time.Sleep(after)
return snoozeFireMsg(alarm)
}
}
func autoTimeoutCmd(dur time.Duration) tea.Cmd {
return func() tea.Msg {
time.Sleep(dur)
return autoTimeoutMsg{}
}
}
func (m *Model) refreshAlarms() {
alarms, err := m.store.ListAlarms()
if err == nil {
m.alarms = alarms
}
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
var sections []string
// Big clock
clockStr := RenderBigClock(m.now, m.settings.BlinkOnMs, m.settings.BlinkOffMs)
if m.firingAlarm != nil && m.firingBlink {
clockStr = ClockAlarmStyle.Render(clockStr)
} else {
clockStr = ClockStyle.Render(clockStr)
}
clockStr = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, clockStr)
sections = append(sections, clockStr)
// Spacer
sections = append(sections, "")
// Date line
dateLine := StatusStyle.Render(m.now.Format("Monday, 02 January 2006"))
dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine)
sections = append(sections, dateLine)
// Snooze indicator
if !m.snoozeUntil.IsZero() && m.now.Before(m.snoozeUntil) {
snoozeText := fmt.Sprintf("[Snoozing %s until %s]", m.snoozeName, m.snoozeUntil.Format("15:04:05"))
snoozeLine := AlarmFiringStyle.Render(snoozeText)
snoozeLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, snoozeLine)
sections = append(sections, snoozeLine)
}
// Firing alarm overlay
if m.firingAlarm != nil {
firingText := fmt.Sprintf("ALARM: %s", m.firingAlarm.Name)
if m.firingAlarm.Description != "" {
firingText += "\n" + m.firingAlarm.Description
}
if m.snoozeCount > 0 {
firingText += fmt.Sprintf("\n(snoozed %d time(s))", m.snoozeCount)
}
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)
}
// Divider
divider := DividerStyle.Render(strings.Repeat("─", min(m.width, 80)))
divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider)
sections = append(sections, "", divider, "")
// 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)
} else {
// Alarm list
alarmList := m.renderAlarmList()
alarmList = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, alarmList)
sections = append(sections, alarmList)
}
// Status message
if m.statusMsg != "" {
status := StatusStyle.Render(m.statusMsg)
sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, status))
}
// Help bar at the bottom
help := m.renderHelp()
sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help))
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
strings.Join(sections, "\n"),
)
}
const (
alarmColTrigger = 18 // enough for cron like "30 7 * * 1-5"
alarmColName = 22
alarmColDesc = 30
)
// Total row width: 6 (prefix) + 18 (trigger) + 22 (name) + 30 (desc) = 76
const alarmRowWidth = 6 + alarmColTrigger + alarmColName + alarmColDesc
func (m Model) renderAlarmList() string {
if len(m.alarms) == 0 {
return StatusStyle.Render("No alarms. Press 'a' to add one.")
}
title := TitleStyle.Render("Alarms")
header := fmt.Sprintf(" %-*s %-*s %-*s",
alarmColTrigger, "Time/Trigger",
alarmColName, "Name",
alarmColDesc, "Description")
var lines []string
lines = append(lines, fmt.Sprintf("%-*s", alarmRowWidth, title), "")
lines = append(lines, ColumnHeaderStyle.Render(fmt.Sprintf("%-*s", alarmRowWidth, header)))
for i, a := range m.alarms {
cursor := " "
if i == m.cursor {
cursor = "▸ "
}
status := " ● "
if !a.Enabled {
status = " ○ "
}
var style lipgloss.Style
if i == m.cursor {
style = AlarmSelectedStyle
} else if a.Enabled {
style = AlarmEnabledStyle
} else {
style = AlarmDisabledStyle
}
trigger := a.Time
if a.Trigger != "once" {
trigger = a.Trigger
}
name := a.Name
if len(name) > alarmColName-1 {
name = name[:alarmColName-2] + "~"
}
desc := a.Description
if len(desc) > alarmColDesc-1 {
desc = desc[:alarmColDesc-2] + "~"
}
// Build line and pad to fixed total width so centering treats all rows equally
raw := fmt.Sprintf("%s%s %-*s %-*s %-*s",
cursor, status,
alarmColTrigger, trigger,
alarmColName, name,
alarmColDesc, desc)
line := fmt.Sprintf("%-*s", alarmRowWidth, raw)
lines = append(lines, style.Render(line))
}
return strings.Join(lines, "\n")
}
func (m Model) renderHelp() string {
switch m.mode {
case viewForm, viewConfig:
return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel")
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")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}