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 ) // 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 // Form state form *formModel // Error/status statusMsg string } func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { return Model{ store: store, scheduler: sched, player: pl, now: time.Now(), } } func (m Model) Init() tea.Cmd { return tea.Batch( tickEveryHalfSecond(), listenForAlarms(m.scheduler), loadAlarmsCmd(m.store), ) } func tickEveryHalfSecond() tea.Cmd { return tea.Every(500*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, tickEveryHalfSecond() case alarmFiredMsg: alarm := msg.Alarm m.startFiring(&alarm) m.refreshAlarms() return m, tea.Batch( listenForAlarms(m.scheduler), autoTimeoutCmd(), ) case snoozeFireMsg: alarm := db.Alarm(msg) m.snoozeCount++ m.startFiring(&alarm) return m, autoTimeoutCmd() case autoTimeoutMsg: if m.firingAlarm != nil { m.player.Stop() m.statusMsg = "Alarm auto-dismissed after 5 minutes" 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 m.firingAlarm = nil m.statusMsg = fmt.Sprintf("Snoozed '%s' for 5 minutes", alarm.Name) return m, snoozeCmd(alarm, snoozeDuration) } return m, nil } // Form mode if m.mode == viewForm && m.form != nil { return m.form.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) } } return m, nil } const snoozeDuration = 5 * time.Minute const autoTimeoutDuration = 5 * time.Minute func (m *Model) startFiring(alarm *db.Alarm) { m.firingAlarm = alarm m.firingBlink = true m.firingStart = time.Now() m.player.PlayLoop(alarm.SoundPath) } func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd { return func() tea.Msg { time.Sleep(after) return snoozeFireMsg(alarm) } } func autoTimeoutCmd() tea.Cmd { return func() tea.Msg { time.Sleep(autoTimeoutDuration) 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) 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) // Date line dateLine := StatusStyle.Render(m.now.Format("Monday, 02 January 2006")) dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine) sections = append(sections, dateLine) // 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 += "\n\n[Enter/Space/d] Dismiss [s] Snooze 5min" 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 view 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 { if m.mode == viewForm { return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel") } if m.mode == viewConfirmDelete { return HelpStyle.Render("y: confirm delete n: cancel") } return HelpStyle.Render("j/k: navigate a: add e: edit d: delete space: toggle q: quit") } func min(a, b int) int { if a < b { return a } return b }