mirror of
https://github.com/RWejlgaard/org.git
synced 2026-09-01 17:05:34 +00:00
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.
This commit is contained in:
parent
7bc00d6891
commit
25dd231073
6 changed files with 355 additions and 92 deletions
3
go.mod
3
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
183
internal/ui/datepicker_test.go
Normal file
183
internal/ui/datepicker_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue