Files
woke/ui/model.go
2026-02-01 21:15:17 +02:00

348 lines
7.3 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
)
// Messages
type tickMsg time.Time
type alarmFiredMsg scheduler.AlarmEvent
// 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
// 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(
tickEverySecond(),
listenForAlarms(m.scheduler),
m.loadAlarms,
)
}
func tickEverySecond() tea.Cmd {
return tea.Every(time.Second, 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 (m *Model) loadAlarms() tea.Msg {
alarms, err := m.store.ListAlarms()
if err != nil {
return nil
}
m.alarms = alarms
return nil
}
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 tickMsg:
m.now = time.Time(msg)
if m.firingAlarm != nil {
m.firingBlink = !m.firingBlink
}
return m, tickEverySecond()
case alarmFiredMsg:
alarm := msg.Alarm
m.firingAlarm = &alarm
m.firingBlink = true
m.player.Play(alarm.SoundPath)
m.refreshAlarms()
return m, listenForAlarms(m.scheduler)
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()
m.firingAlarm = nil
m.statusMsg = "Alarm dismissed"
case "s":
m.player.Stop()
m.firingAlarm = nil
m.statusMsg = "Snoozed for 5 minutes (not yet implemented)"
// TODO: implement snooze by creating a one-shot alarm 5min from now
}
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
}
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
}
firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze"
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.Top,
strings.Join(sections, "\n"),
)
}
func (m Model) renderAlarmList() string {
if len(m.alarms) == 0 {
return StatusStyle.Render("No alarms. Press 'a' to add one.")
}
title := TitleStyle.Render("Alarms")
var lines []string
lines = append(lines, title, "")
for i, a := range m.alarms {
cursor := " "
if i == m.cursor {
cursor = "▸ "
}
status := "●"
var style lipgloss.Style
if a.Enabled {
style = AlarmEnabledStyle
} else {
style = AlarmDisabledStyle
status = "○"
}
if i == m.cursor {
style = AlarmSelectedStyle
}
trigger := a.Time
if a.Trigger != "once" {
trigger = a.Trigger
}
line := fmt.Sprintf("%s%s %s %-20s %s", cursor, status, trigger, a.Name, a.Description)
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
}