First draft

This commit is contained in:
2026-02-01 21:15:17 +02:00
parent 191ec01e73
commit 06d5a6a779
11 changed files with 1352 additions and 0 deletions

161
ui/clock.go Normal file
View File

@@ -0,0 +1,161 @@
package ui
import (
"fmt"
"strings"
"time"
)
// Each digit is 8 chars wide x 7 lines tall, using block characters.
var bigDigits = [10][]string{
// 0
{
" ██████ ",
"██ ██",
"██ ████",
"██ ██ ██",
"████ ██",
"██ ██",
" ██████ ",
},
// 1
{
" ██ ",
" ████ ",
" ██ ",
" ██ ",
" ██ ",
" ██ ",
" ██████",
},
// 2
{
" ██████ ",
"██ ██",
" ██",
" █████ ",
"██ ",
"██ ██",
"████████",
},
// 3
{
" ██████ ",
"██ ██",
" ██",
" █████ ",
" ██",
"██ ██",
" ██████ ",
},
// 4
{
"██ ██",
"██ ██",
"██ ██",
"████████",
" ██",
" ██",
" ██",
},
// 5
{
"████████",
"██ ",
"██ ",
"███████ ",
" ██",
"██ ██",
" ██████ ",
},
// 6
{
" ██████ ",
"██ ",
"██ ",
"███████ ",
"██ ██",
"██ ██",
" ██████ ",
},
// 7
{
"████████",
"██ ██",
" ██ ",
" ██ ",
" ██ ",
" ██ ",
" ██ ",
},
// 8
{
" ██████ ",
"██ ██",
"██ ██",
" ██████ ",
"██ ██",
"██ ██",
" ██████ ",
},
// 9
{
" ██████ ",
"██ ██",
"██ ██",
" ███████",
" ██",
" ██",
" ██████ ",
},
}
var bigColon = []string{
" ",
" ██ ",
" ██ ",
" ",
" ██ ",
" ██ ",
" ",
}
var bigColonBlink = []string{
" ",
" ",
" ",
" ",
" ",
" ",
" ",
}
// RenderBigClock renders the current time as massive ASCII block digits.
// Format: HH:MM:SS in 24h. The colon blinks every second.
func RenderBigClock(t time.Time) string {
h := t.Hour()
m := t.Minute()
s := t.Second()
timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s)
var lines [7]string
for i := range lines {
var parts []string
for _, ch := range timeStr {
switch {
case ch >= '0' && ch <= '9':
parts = append(parts, bigDigits[ch-'0'][i])
case ch == ':':
if s%2 == 0 {
parts = append(parts, bigColon[i])
} else {
parts = append(parts, bigColonBlink[i])
}
}
}
lines[i] = strings.Join(parts, " ")
}
return strings.Join(lines[:], "\n")
}

215
ui/form.go Normal file
View File

@@ -0,0 +1,215 @@
package ui
import (
"fmt"
"strings"
"woke/db"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/robfig/cron/v3"
)
type formField int
const (
fieldName formField = iota
fieldDescription
fieldTime
fieldTrigger
fieldSound
fieldCount // sentinel
)
type formModel struct {
editing *db.Alarm // nil = creating new
fields [fieldCount]string
active formField
err string
}
func newFormModel(alarm *db.Alarm) *formModel {
f := &formModel{}
if alarm != nil {
f.editing = alarm
f.fields[fieldName] = alarm.Name
f.fields[fieldDescription] = alarm.Description
f.fields[fieldTime] = alarm.Time
f.fields[fieldTrigger] = alarm.Trigger
f.fields[fieldSound] = alarm.SoundPath
} else {
f.fields[fieldTrigger] = "once"
f.fields[fieldSound] = "default"
}
return f
}
func (f *formModel) Update(msg tea.Msg, m *Model) (tea.Model, tea.Cmd) {
return *m, nil
}
func (f *formModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "esc", "escape":
m.mode = viewMain
m.form = nil
m.statusMsg = ""
return *m, nil
case "tab", "down":
f.active = (f.active + 1) % fieldCount
return *m, nil
case "shift+tab", "up":
f.active = (f.active - 1 + fieldCount) % fieldCount
return *m, nil
case "enter":
return f.save(m)
case "backspace":
field := &f.fields[f.active]
if len(*field) > 0 {
*field = (*field)[:len(*field)-1]
}
f.err = ""
return *m, nil
case "ctrl+u":
f.fields[f.active] = ""
f.err = ""
return *m, nil
default:
// Only accept printable characters
if len(key) == 1 {
f.fields[f.active] += key
f.err = ""
}
return *m, nil
}
}
func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) {
name := strings.TrimSpace(f.fields[fieldName])
if name == "" {
f.err = "Name is required"
return *m, nil
}
timeStr := strings.TrimSpace(f.fields[fieldTime])
trigger := strings.TrimSpace(f.fields[fieldTrigger])
if trigger == "once" || trigger == "" {
trigger = "once"
// Validate HH:MM format
if !isValidTime(timeStr) {
f.err = "Time must be in HH:MM format (00:00-23:59)"
return *m, nil
}
} else {
// Validate cron expression
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
if _, err := parser.Parse(trigger); err != nil {
f.err = fmt.Sprintf("Invalid cron expression: %v", err)
return *m, nil
}
}
soundPath := strings.TrimSpace(f.fields[fieldSound])
if soundPath == "" {
soundPath = "default"
}
alarm := db.Alarm{
Name: name,
Description: strings.TrimSpace(f.fields[fieldDescription]),
Time: timeStr,
Trigger: trigger,
SoundPath: soundPath,
Enabled: true,
}
if f.editing != nil {
alarm.ID = f.editing.ID
alarm.Enabled = f.editing.Enabled
if err := m.store.UpdateAlarm(alarm); err != nil {
f.err = fmt.Sprintf("Save failed: %v", err)
return *m, nil
}
m.statusMsg = "Alarm updated"
} else {
if _, err := m.store.CreateAlarm(alarm); err != nil {
f.err = fmt.Sprintf("Save failed: %v", err)
return *m, nil
}
m.statusMsg = "Alarm created"
}
m.refreshAlarms()
m.mode = viewMain
m.form = nil
return *m, nil
}
func (f *formModel) View() string {
title := "Add Alarm"
if f.editing != nil {
title = "Edit Alarm"
}
labels := [fieldCount]string{
"Name: ",
"Description: ",
"Time (HH:MM):",
"Trigger: ",
"Sound: ",
}
hints := [fieldCount]string{
"",
"(optional)",
"e.g. 07:30",
"'once' or cron: 30 7 * * 1-5",
"'default' or path to sound file",
}
var lines []string
lines = append(lines, TitleStyle.Render(title), "")
for i := formField(0); i < fieldCount; i++ {
label := FormLabelStyle.Render(labels[i])
value := f.fields[i]
hint := HelpStyle.Render(hints[i])
var style lipgloss.Style
if i == f.active {
style = FormActiveStyle
value += "█" // cursor
} else {
style = FormLabelStyle
}
line := fmt.Sprintf(" %s %s %s", label, style.Render(value), hint)
lines = append(lines, line)
}
if f.err != "" {
lines = append(lines, "", FormErrorStyle.Render(" Error: "+f.err))
}
return strings.Join(lines, "\n")
}
func isValidTime(s string) bool {
if len(s) != 5 || s[2] != ':' {
return false
}
h := (s[0]-'0')*10 + (s[1] - '0')
m := (s[3]-'0')*10 + (s[4] - '0')
return h <= 23 && m <= 59 && s[0] >= '0' && s[0] <= '9' &&
s[1] >= '0' && s[1] <= '9' && s[3] >= '0' && s[3] <= '9' &&
s[4] >= '0' && s[4] <= '9'
}

347
ui/model.go Normal file
View File

@@ -0,0 +1,347 @@
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
}

57
ui/styles.go Normal file
View File

@@ -0,0 +1,57 @@
package ui
import "github.com/charmbracelet/lipgloss"
var (
// Clock colors
ClockStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF88")).
Bold(true)
ClockAlarmStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF4444")).
Bold(true)
// Alarm list
AlarmEnabledStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF88"))
AlarmDisabledStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
AlarmSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFF00")).
Bold(true)
// Status bar
StatusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
HelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#555555"))
// Alarm firing overlay
AlarmFiringStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Bold(true).
Blink(true)
// Form
FormLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
FormActiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF88")).
Bold(true)
FormErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF4444"))
TitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF88")).
Bold(true).
Underline(true)
DividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#333333"))
)