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' }