230 lines
4.8 KiB
Go
230 lines
4.8 KiB
Go
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.alarmStore.UpdateAlarm(alarm); err != nil {
|
|
f.err = fmt.Sprintf("Save failed: %v", err)
|
|
return *m, nil
|
|
}
|
|
m.statusMsg = "Alarm updated"
|
|
} else {
|
|
if _, err := m.alarmStore.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
|
|
}
|
|
|
|
const (
|
|
formLabelWidth = 14 // longest label is "Time (HH:MM):" = 13 + 1 padding
|
|
formValueWidth = 30
|
|
formHintWidth = 32
|
|
)
|
|
|
|
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++ {
|
|
// Right-align labels in a fixed-width column
|
|
labelStr := fmt.Sprintf("%*s", formLabelWidth, labels[i])
|
|
|
|
value := f.fields[i]
|
|
var style lipgloss.Style
|
|
if i == f.active {
|
|
style = FormActiveStyle
|
|
value += "█"
|
|
} else {
|
|
style = FormLabelStyle
|
|
}
|
|
|
|
// Truncate from the left if too long, then pad to fixed width
|
|
if len(value) > formValueWidth {
|
|
value = value[len(value)-formValueWidth:]
|
|
}
|
|
valueStr := fmt.Sprintf("%-*s", formValueWidth, value)
|
|
|
|
hintStr := fmt.Sprintf("%-*s", formHintWidth, hints[i])
|
|
|
|
line := FormLabelStyle.Render(labelStr) + " " + style.Render(valueStr) + " " + HelpStyle.Render(hintStr)
|
|
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'
|
|
}
|