diff --git a/ui/clock.go b/ui/clock.go index 1fa7796..2ff52d5 100644 --- a/ui/clock.go +++ b/ui/clock.go @@ -131,7 +131,8 @@ var bigColonBlink = []string{ } // RenderBigClock renders the current time as massive ASCII block digits. -// Format: HH:MM:SS in 24h. The colon blinks every second. +// Format: HH:MM:SS in 24h. The colon blinks on a 500ms cycle (offset from +// the second boundary so it doesn't flip in sync with the digits). func RenderBigClock(t time.Time) string { h := t.Hour() m := t.Minute() @@ -139,6 +140,9 @@ func RenderBigClock(t time.Time) string { timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s) + // Colon visible for first 500ms of each second, hidden for last 500ms. + colonVisible := t.UnixMilli()%1000 < 500 + var lines [7]string for i := range lines { var parts []string @@ -147,7 +151,7 @@ func RenderBigClock(t time.Time) string { case ch >= '0' && ch <= '9': parts = append(parts, bigDigits[ch-'0'][i]) case ch == ':': - if s%2 == 0 { + if colonVisible { parts = append(parts, bigColon[i]) } else { parts = append(parts, bigColonBlink[i]) diff --git a/ui/form.go b/ui/form.go index daadc3e..b4540d8 100644 --- a/ui/form.go +++ b/ui/form.go @@ -154,6 +154,12 @@ func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) { 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 { @@ -161,11 +167,11 @@ func (f *formModel) View() string { } labels := [fieldCount]string{ - "Name: ", - "Description: ", + "Name:", + "Description:", "Time (HH:MM):", - "Trigger: ", - "Sound: ", + "Trigger:", + "Sound:", } hints := [fieldCount]string{ @@ -180,19 +186,27 @@ func (f *formModel) View() 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]) + // 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 += "█" // cursor + value += "█" } else { style = FormLabelStyle } - line := fmt.Sprintf(" %s %s %s", label, style.Render(value), hint) + // 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) } diff --git a/ui/model.go b/ui/model.go index 10b17fe..235c0f5 100644 --- a/ui/model.go +++ b/ui/model.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) // View modes @@ -24,6 +25,7 @@ const ( // Messages type tickMsg time.Time type alarmFiredMsg scheduler.AlarmEvent +type alarmsLoadedMsg []db.Alarm // Model is the main bubbletea model. type Model struct { @@ -61,14 +63,14 @@ func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Mo func (m Model) Init() tea.Cmd { return tea.Batch( - tickEverySecond(), + tickEveryHalfSecond(), listenForAlarms(m.scheduler), - m.loadAlarms, + loadAlarmsCmd(m.store), ) } -func tickEverySecond() tea.Cmd { - return tea.Every(time.Second, func(t time.Time) tea.Msg { +func tickEveryHalfSecond() tea.Cmd { + return tea.Every(500*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) } @@ -80,13 +82,14 @@ func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd { } } -func (m *Model) loadAlarms() tea.Msg { - alarms, err := m.store.ListAlarms() - if err != nil { - return nil +func loadAlarmsCmd(store *db.Store) tea.Cmd { + return func() tea.Msg { + alarms, err := store.ListAlarms() + if err != nil { + return alarmsLoadedMsg(nil) + } + return alarmsLoadedMsg(alarms) } - m.alarms = alarms - return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -96,12 +99,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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, tickEverySecond() + return m, tickEveryHalfSecond() case alarmFiredMsg: alarm := msg.Alarm @@ -284,37 +293,64 @@ func (m Model) View() string { help := m.renderHelp() sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help)) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Top, + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, strings.Join(sections, "\n"), ) } +const ( + alarmColPrefix = 6 // "▸ ● " = 4 display cols + 2 padding + alarmColTrigger = 18 // enough for cron like "30 7 * * 1-5" + alarmColName = 22 +) + +// padRight pads s to exactly width display columns using runewidth. +func padRight(s string, width int) string { + w := runewidth.StringWidth(s) + if w >= width { + return runewidth.Truncate(s, width, "…") + } + return s + strings.Repeat(" ", width-w) +} + 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 — prefix padded to same display width as row prefixes + header := padRight("", alarmColPrefix) + + padRight("Time/Trigger", alarmColTrigger) + + padRight("Name", alarmColName) + + "Description" + var lines []string lines = append(lines, title, "") + lines = append(lines, DividerStyle.Render(header)) for i, a := range m.alarms { - cursor := " " + var prefix string if i == m.cursor { - cursor = "▸ " + prefix = "▸ " + } else { + prefix = " " } - status := "●" - var style lipgloss.Style if a.Enabled { + prefix += "● " + } else { + prefix += "○ " + } + + var style lipgloss.Style + if i == m.cursor { + style = AlarmSelectedStyle + } else if a.Enabled { style = AlarmEnabledStyle } else { style = AlarmDisabledStyle - status = "○" - } - - if i == m.cursor { - style = AlarmSelectedStyle } trigger := a.Time @@ -322,7 +358,11 @@ func (m Model) renderAlarmList() string { trigger = a.Trigger } - line := fmt.Sprintf("%s%s %s %-20s %s", cursor, status, trigger, a.Name, a.Description) + line := padRight(prefix, alarmColPrefix) + + padRight(trigger, alarmColTrigger) + + padRight(a.Name, alarmColName) + + a.Description + lines = append(lines, style.Render(line)) } diff --git a/woke b/woke index 922ae3c..28eb52f 100755 Binary files a/woke and b/woke differ