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{} type AlarmsChangedMsg 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 AlarmsChangedMsg: m.refreshAlarms() 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, m.settings.ShowSeconds) 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 }