439 lines
9.5 KiB
Go
439 lines
9.5 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
|
|
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
|
|
}
|