From 25dd231073ff2a43c1417d376845c94403d6a6b0 Mon Sep 17 00:00:00 2001 From: Taybin Rutkin Date: Tue, 21 Jul 2026 16:15:24 -0400 Subject: [PATCH] feat: datepicker added to deadline selection (#20) * feat: add datepicker dependency and init helper * feat: calendar-first set-date modes with text fallback * feat: initialize datepicker when opening set-date modes * feat: render calendar and hints in set-date view * chore: go mod tidy for bubble-datepicker direct dependency Promotes github.com/ethanefung/bubble-datepicker from indirect to a direct require (it is imported directly by internal/ui) and drops the now-unused golang.org/x/text v0.3.8 checksums. --- go.mod | 3 +- go.sum | 6 +- internal/ui/app.go | 3 + internal/ui/datepicker_test.go | 183 +++++++++++++++++++++++++ internal/ui/modes.go | 242 +++++++++++++++++++++------------ internal/ui/views.go | 10 +- 6 files changed, 355 insertions(+), 92 deletions(-) create mode 100644 internal/ui/datepicker_test.go diff --git a/go.mod b/go.mod index b1a746c..cd47023 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/ethanefung/bubble-datepicker v0.1.1 ) require ( @@ -29,5 +30,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index cad1077..84ae095 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/ethanefung/bubble-datepicker v0.1.1 h1:+12ZTE4ANZ2cAgYURXzDwG0z+rDMwo4Ljfxhs5B0okc= +github.com/ethanefung/bubble-datepicker v0.1.1/go.mod h1:8nxOYB9Oqays5U0JHKcIsbT7ZP/TwuJz8Uju9n5ueVU= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -61,5 +63,5 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= diff --git a/internal/ui/app.go b/internal/ui/app.go index b19e202..2de51e1 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + datepicker "github.com/ethanefung/bubble-datepicker" "github.com/rwejlgaard/org/internal/config" "github.com/rwejlgaard/org/internal/model" ) @@ -54,6 +55,8 @@ type uiModel struct { settingsScroll int // Scroll position in settings view settingsSection settingsSection // Current settings section/tab captureCursor int // Store cursor position when entering capture mode + datepicker datepicker.Model + dateTextFocused bool // within set-date modes: text field focused vs. calendar } func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel { diff --git a/internal/ui/datepicker_test.go b/internal/ui/datepicker_test.go new file mode 100644 index 0000000..e2e249c --- /dev/null +++ b/internal/ui/datepicker_test.go @@ -0,0 +1,183 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + datepicker "github.com/ethanefung/bubble-datepicker" + "github.com/rwejlgaard/org/internal/config" + "github.com/rwejlgaard/org/internal/model" +) + +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() +} + +func TestNewDatePickerDefaultsToToday(t *testing.T) { + dp := newDatePicker(nil) + if !sameDay(dp.Time, time.Now()) { + t.Fatalf("expected today, got %v", dp.Time) + } + if !dp.Selected { + t.Fatal("expected date to be selected") + } + if dp.Focused != datepicker.FocusCalendar { + t.Fatalf("expected FocusCalendar, got %v", dp.Focused) + } + // Quit binding must be disabled so 'q' does not quit the app from the calendar. + if dp.KeyMap.Quit.Enabled() && len(dp.KeyMap.Quit.Keys()) > 0 { + t.Fatalf("expected Quit binding disabled, got keys %v", dp.KeyMap.Quit.Keys()) + } + // Forwarding a 'q' key must not produce tea.Quit. + _, cmd := dp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd != nil { + t.Fatal("expected no command from 'q' after disabling Quit") + } +} + +func TestNewDatePickerUsesExisting(t *testing.T) { + existing := time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local) + dp := newDatePicker(&existing) + if !sameDay(dp.Time, existing) { + t.Fatalf("expected %v, got %v", existing, dp.Time) + } +} + +func newDateModeModel(dateType string, existing *time.Time) (uiModel, *model.Item) { + cfg := config.DefaultConfig() + item := &model.Item{Title: "Task", Level: 1, Deadline: existing, Scheduled: existing} + of := &model.OrgFile{Items: []*model.Item{item}} + m := InitialModel(of, cfg, false, "") + m.editingItem = item + m.dateTextFocused = false + m.datepicker = newDatePicker(existing) + if dateType == "DEADLINE" { + m.mode = modeSetDeadline + } else { + m.mode = modeSetScheduled + } + return m, item +} + +func TestCalendarEnterAppliesDate(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.datepicker.SetTime(time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local)) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline == nil || !sameDay(*item.Deadline, time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local)) { + t.Fatalf("expected deadline 2027-03-09, got %v", item.Deadline) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestCalendarXClearsDate(t *testing.T) { + existing := time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local) + m, item := newDateModeModel("SCHEDULED", &existing) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, "SCHEDULED") + rm := res.(uiModel) + if item.Scheduled != nil { + t.Fatalf("expected scheduled cleared, got %v", item.Scheduled) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestDigitSwitchesToTextFocus(t *testing.T) { + m, _ := newDateModeModel("DEADLINE", nil) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}, "DEADLINE") + rm := res.(uiModel) + if !rm.dateTextFocused { + t.Fatal("expected text focus after typing a digit") + } + if rm.textinput.Value() != "2" { + t.Fatalf("expected text seeded with '2', got %q", rm.textinput.Value()) + } + if rm.mode != modeSetDeadline { + t.Fatalf("expected to stay in deadline mode, got %v", rm.mode) + } +} + +func TestTextEnterParsesRelativeDate(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("+7") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + want := time.Now().AddDate(0, 0, 7) + if item.Deadline == nil || !sameDay(*item.Deadline, want) { + t.Fatalf("expected deadline %v, got %v", want, item.Deadline) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestTextEnterInvalidStaysInTextFocus(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("not-a-date") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline != nil { + t.Fatalf("expected no deadline set, got %v", item.Deadline) + } + if rm.mode != modeSetDeadline || !rm.dateTextFocused { + t.Fatalf("expected to stay in text focus, got mode=%v textFocused=%v", rm.mode, rm.dateTextFocused) + } +} + +func TestTextEscReturnsToCalendar(t *testing.T) { + m, _ := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("+3") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEsc}, "DEADLINE") + rm := res.(uiModel) + if rm.dateTextFocused { + t.Fatal("expected to return to calendar focus") + } + if rm.mode != modeSetDeadline { + t.Fatalf("expected to stay in deadline mode, got %v", rm.mode) + } + if rm.textinput.Value() != "" { + t.Fatalf("expected text cleared, got %q", rm.textinput.Value()) + } +} + +func TestCalendarEscCancels(t *testing.T) { + existing := time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local) + m, item := newDateModeModel("DEADLINE", &existing) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEsc}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline == nil { + t.Fatal("expected existing deadline untouched on cancel") + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestOpeningDeadlineModeInitializesCalendar(t *testing.T) { + cfg := config.DefaultConfig() + item := &model.Item{Title: "Task", Level: 1} + of := &model.OrgFile{Items: []*model.Item{item}} + m := InitialModel(of, cfg, false, "") + // default deadline key is "d" + res, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + rm := res.(uiModel) + if rm.mode != modeSetDeadline { + t.Fatalf("expected modeSetDeadline, got %v", rm.mode) + } + if !rm.datepicker.Selected { + t.Fatal("expected datepicker to have a selected date") + } + if rm.dateTextFocused { + t.Fatal("expected calendar focus (dateTextFocused false)") + } + if rm.editingItem != item { + t.Fatal("expected editingItem to be the cursor item") + } +} diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 96300f8..b100a3c 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + datepicker "github.com/ethanefung/bubble-datepicker" "github.com/rwejlgaard/org/internal/model" "github.com/rwejlgaard/org/internal/parser" ) @@ -317,10 +318,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(items) > 0 && m.cursor < len(items) { m.editingItem = items[m.cursor] m.mode = modeSetDeadline + m.datepicker = newDatePicker(m.editingItem.Deadline) + m.dateTextFocused = false m.textinput.SetValue("") m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)" - m.textinput.Focus() - return m, textinput.Blink + m.textinput.Blur() + return m, nil } case key.Matches(msg, m.keys.SetScheduled): @@ -328,10 +331,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(items) > 0 && m.cursor < len(items) { m.editingItem = items[m.cursor] m.mode = modeSetScheduled + m.datepicker = newDatePicker(m.editingItem.Scheduled) + m.dateTextFocused = false m.textinput.SetValue("") m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)" - m.textinput.Focus() - return m, textinput.Blink + m.textinput.Blur() + return m, nil } case key.Matches(msg, m.keys.SetPriority): @@ -573,6 +578,21 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// newDatePicker returns a calendar-focused datepicker seeded with existing (if +// non-nil) or today. The Quit binding is cleared so 'q'/ctrl+c do not quit the +// app while the calendar has focus. +func newDatePicker(existing *time.Time) datepicker.Model { + t := time.Now() + if existing != nil { + t = *existing + } + dp := datepicker.New(t) + dp.SelectDate() + dp.SetFocus(datepicker.FocusCalendar) + dp.KeyMap.Quit = key.Binding{} + return dp +} + func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSetDate(msg, "DEADLINE") } @@ -610,102 +630,150 @@ func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSetDate(msg, "SCHEDULED") } -func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) { - var cmd tea.Cmd +// applyDate sets the deadline/scheduled date on the editing item and syncs its +// property line in Notes. +func (m *uiModel) applyDate(dateType string, dateVal time.Time) { + prefixDate := "SCHEDULED:" + setDateMsg := "Scheduled date set!" + if dateType == "DEADLINE" { + prefixDate = "DEADLINE:" + setDateMsg = "Deadline set!" + m.editingItem.Deadline = &dateVal + } else { + m.editingItem.Scheduled = &dateVal + } + updatedNotes := false + for i, note := range m.editingItem.Notes { + if strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal)) + updatedNotes = true + break + } + } + if !updatedNotes { + var filtered []string + for _, note := range m.editingItem.Notes { + if !strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + filtered = append(filtered, note) + } + } + m.editingItem.Notes = filtered + } + m.setStatus(setDateMsg) +} + +// clearDate removes the deadline/scheduled date and its property line. +func (m *uiModel) clearDate(dateType string) { + prefixDate := "SCHEDULED:" + clearedMsg := "Scheduled date cleared!" + if dateType == "DEADLINE" { + prefixDate = "DEADLINE:" + clearedMsg = "Deadline cleared!" + m.editingItem.Deadline = nil + } else { + m.editingItem.Scheduled = nil + } + + var filtered []string + for _, note := range m.editingItem.Notes { + if !strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + filtered = append(filtered, note) + } + } + m.editingItem.Notes = filtered + m.setStatus(clearedMsg) +} + +func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.textinput.Width = 50 - + return m, nil case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - input := strings.TrimSpace(m.textinput.Value()) - if m.editingItem != nil { - var prefixDate string - var clearedDateMsg string - var setDateMsg string + if m.dateTextFocused { + return m.updateSetDateText(msg, dateType) + } + return m.updateSetDateCalendar(msg, dateType) + } + // Forward other messages (e.g. cursor blink) to the text input when focused. + if m.dateTextFocused { + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd + } + return m, nil +} - if dateType == "DEADLINE" { - prefixDate = "DEADLINE:" - clearedDateMsg = "Deadline cleared!" - setDateMsg = "Deadline set!" - } else { - prefixDate = "SCHEDULED:" - clearedDateMsg = "Scheduled date cleared!" - setDateMsg = "Scheduled date set!" - } - - if input == "" { - // Empty input clears the date - if dateType == "DEADLINE" { - m.editingItem.Deadline = nil - } else { - m.editingItem.Scheduled = nil - } - - // Remove property line from notes - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, prefixDate) { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - m.setStatus(clearedDateMsg) - } else { - dateVal, err := parseDateInput(input) - if err != nil { - m.setStatus(fmt.Sprintf("Invalid date: %v", err)) - } else { - if dateType == "DEADLINE" { - m.editingItem.Deadline = &dateVal - } else { - m.editingItem.Scheduled = &dateVal - } - - // Also update or add property line in notes - updatedNotes := false - for i, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if strings.HasPrefix(trimmedNote, prefixDate) { - m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal)) - updatedNotes = true - break - } - } - // If property wasn't in notes, it will be added by writeItem - if !updatedNotes { - // Remove old property lines just to be safe - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, prefixDate) { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - } - m.setStatus(setDateMsg) - } +// closeDateMode returns to the list view and clears set-date state. +func (m uiModel) closeDateMode() (tea.Model, tea.Cmd) { + m.mode = modeList + m.textinput.Blur() + m.textinput.SetValue("") + m.dateTextFocused = false + m.editingItem = nil + return m, nil +} + +func (m uiModel) updateSetDateCalendar(msg tea.KeyMsg, dateType string) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + if m.editingItem != nil { + m.applyDate(dateType, m.datepicker.Time) + } + return m.closeDateMode() + case tea.KeyEsc: + m.setStatus("Cancelled") + return m.closeDateMode() + case tea.KeyRunes: + if len(msg.Runes) == 1 { + r := msg.Runes[0] + if r == 'x' { + if m.editingItem != nil { + m.clearDate(dateType) } + return m.closeDateMode() + } + if (r >= '0' && r <= '9') || r == '+' { + m.dateTextFocused = true + m.textinput.SetValue(string(r)) + m.textinput.CursorEnd() + m.textinput.Focus() + return m, textinput.Blink } - m.mode = modeList - m.textinput.Blur() - m.editingItem = nil - return m, nil - case tea.KeyEsc: - m.mode = modeList - m.textinput.Blur() - m.editingItem = nil - m.setStatus("Cancelled") - return m, nil } } + m.datepicker, _ = m.datepicker.Update(msg) + return m, nil +} +func (m uiModel) updateSetDateText(msg tea.KeyMsg, dateType string) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + input := strings.TrimSpace(m.textinput.Value()) + if m.editingItem == nil { + return m.closeDateMode() + } + if input == "" { + m.clearDate(dateType) + return m.closeDateMode() + } + dateVal, err := parseDateInput(input) + if err != nil { + m.setStatus(fmt.Sprintf("Invalid date: %v", err)) + return m, nil // stay in text focus so the user can fix it + } + m.applyDate(dateType, dateVal) + return m.closeDateMode() + case tea.KeyEsc: + m.dateTextFocused = false + m.textinput.Blur() + m.textinput.SetValue("") + return m, nil + } + var cmd tea.Cmd m.textinput, cmd = m.textinput.Update(msg) return m, cmd } diff --git a/internal/ui/views.go b/internal/ui/views.go index 5c2772d..101d087 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -402,13 +402,19 @@ func (m uiModel) viewSetDate(title, helpMsg string) string { content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) } content.WriteString("\n\n") + content.WriteString(m.datepicker.View()) + content.WriteString("\n\n") content.WriteString(m.textinput.View()) content.WriteString("\n\n") - content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) + if m.dateTextFocused { + content.WriteString(m.styles.statusStyle.Render("Type YYYY-MM-DD or +N • Enter apply • Esc back to calendar")) + } else { + content.WriteString(m.styles.statusStyle.Render("↑↓←→/hjkl navigate • Tab month/year • Enter select • type a date or +N • x clear")) + } content.WriteString("\n") content.WriteString(m.styles.statusStyle.Render(helpMsg)) content.WriteString("\n") - content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) + content.WriteString(m.styles.statusStyle.Render("Esc to cancel")) dialog := dialogStyle.Render(content.String())