554 lines
13 KiB
Go
554 lines
13 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{}
|
|
type AlarmsChangedMsg struct{}
|
|
type pollTickMsg struct{}
|
|
|
|
// Model is the main bubbletea model.
|
|
type Model struct {
|
|
store *db.Store // For settings (always local)
|
|
alarmStore db.AlarmStore // For alarm CRUD (local or remote)
|
|
scheduler *scheduler.Scheduler
|
|
player *player.Player
|
|
clientMode bool
|
|
|
|
// 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, alarmStore db.AlarmStore, sched *scheduler.Scheduler, pl *player.Player, clientMode bool) Model {
|
|
m := Model{
|
|
store: store,
|
|
alarmStore: alarmStore,
|
|
scheduler: sched,
|
|
player: pl,
|
|
clientMode: clientMode,
|
|
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 {
|
|
cmds := []tea.Cmd{
|
|
tick(),
|
|
listenForAlarms(m.scheduler),
|
|
loadAlarmsCmd(m.alarmStore),
|
|
}
|
|
if m.clientMode {
|
|
cmds = append(cmds, pollTick(m.settings.PollSeconds))
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
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.AlarmStore) tea.Cmd {
|
|
return func() tea.Msg {
|
|
alarms, err := store.ListAlarms()
|
|
if err != nil {
|
|
return alarmsLoadedMsg(nil)
|
|
}
|
|
return alarmsLoadedMsg(alarms)
|
|
}
|
|
}
|
|
|
|
func pollTick(seconds int) tea.Cmd {
|
|
return tea.Tick(time.Duration(seconds)*time.Second, func(t time.Time) tea.Msg {
|
|
return pollTickMsg{}
|
|
})
|
|
}
|
|
|
|
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 pollTickMsg:
|
|
m.refreshAlarms()
|
|
return m, pollTick(m.settings.PollSeconds)
|
|
|
|
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.alarmStore.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.alarmStore.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.alarmStore.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)
|
|
|
|
// Connection status
|
|
var connStatus string
|
|
if m.clientMode {
|
|
connStatus = fmt.Sprintf("[Client: %s]", m.settings.ServerURL)
|
|
} else {
|
|
connStatus = "[Local]"
|
|
}
|
|
connLine := HelpStyle.Render(connStatus)
|
|
connLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, connLine)
|
|
sections = append(sections, connLine)
|
|
|
|
// 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
|
|
}
|