From 70b6ee9bc7d727ced01e0aaba94965f2dee4b4d6 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 2 Feb 2026 20:43:08 +0100 Subject: [PATCH] Add picker to task edit --- components/input/multiselect.go | 5 ++ components/picker/picker.go | 56 +++++++++++++---- pages/taskEditor.go | 106 +++++++++++++++++++++++++++----- 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/components/input/multiselect.go b/components/input/multiselect.go index b54586f..169707c 100644 --- a/components/input/multiselect.go +++ b/components/input/multiselect.go @@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any { return *m.value } +// IsFiltering returns true if the multi-select is currently filtering. +func (m *MultiSelect) IsFiltering() bool { + return m.filtering +} + func min(a, b int) int { if a < b { return a diff --git a/components/picker/picker.go b/components/picker/picker.go index 71d820a..2d47428 100644 --- a/components/picker/picker.go +++ b/components/picker/picker.go @@ -37,6 +37,7 @@ type Picker struct { title string filterByDefault bool baseItems []list.Item + focused bool } type PickerOption func(*Picker) @@ -53,6 +54,24 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption { } } +func (p *Picker) Focus() tea.Cmd { + p.focused = true + return nil +} + +func (p *Picker) Blur() tea.Cmd { + p.focused = false + return nil +} + +func (p *Picker) GetValue() string { + item := p.list.SelectedItem() + if item == nil { + return "" + } + return item.FilterValue() +} + func New( c *common.Common, title string, @@ -82,6 +101,7 @@ func New( itemProvider: itemProvider, onSelect: onSelect, title: title, + focused: true, } if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { @@ -92,6 +112,14 @@ func New( opt(p) } + if p.filterByDefault { + // Manually trigger filter mode on the list so it doesn't require a global key press + var cmd tea.Cmd + p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + // We can ignore the command here as it's likely just for blinking, which will happen on Init anyway + _ = cmd + } + p.Refresh() return p @@ -134,27 +162,26 @@ func (p *Picker) SetSize(width, height int) { } func (p *Picker) Init() tea.Cmd { - if p.filterByDefault { - return func() tea.Msg { - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}} - } - } return nil } func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !p.focused { + return p, nil + } + var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // If filtering, let the list handle keys (including Enter to stop filtering) if p.list.FilterState() == list.Filtering { - if key.Matches(msg, p.common.Keymap.Ok) { - items := p.list.VisibleItems() - if len(items) == 1 { - return p, p.handleSelect(items[0]) - } - } + // if key.Matches(msg, p.common.Keymap.Ok) { + // items := p.list.VisibleItems() + // if len(items) == 1 { + // return p, p.handleSelect(items[0]) + // } + // } break // Pass to list.Update } @@ -189,7 +216,12 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd { } func (p *Picker) View() string { - title := p.common.Styles.Form.Focused.Title.Render(p.title) + var title string + if p.focused { + title = p.common.Styles.Form.Focused.Title.Render(p.title) + } else { + title = p.common.Styles.Form.Blurred.Title.Render(p.title) + } return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View()) } diff --git a/pages/taskEditor.go b/pages/taskEditor.go index a7e7c68..23aafd9 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -8,6 +8,7 @@ import ( "time" "tasksquire/components/input" + "tasksquire/components/picker" "tasksquire/components/timestampeditor" "tasksquire/taskwarrior" @@ -58,7 +59,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag tagOptions := p.common.TW.GetTags() p.areas = []area{ - NewTaskEdit(p.common, &p.task), + NewTaskEdit(p.common, &p.task, p.task.Uuid == ""), NewTagEdit(p.common, &p.task.Tags, tagOptions), NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until), NewDetailsEdit(p.common, &p.task), @@ -110,7 +111,11 @@ func (p *TaskEditorPage) SetSize(width, height int) { } func (p *TaskEditorPage) Init() tea.Cmd { - return nil + var cmds []tea.Cmd + for _, a := range p.areas { + cmds = append(cmds, a.Init()) + } + return tea.Batch(cmds...) } func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -257,9 +262,13 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, nil case key.Matches(msg, p.common.Keymap.Ok): + isFiltering := p.areas[p.area].IsFiltering() model, cmd := p.areas[p.area].Update(msg) if p.area != 3 { p.areas[p.area] = model.(area) + if isFiltering { + return p, cmd + } return p, tea.Batch(cmd, nextField()) } return p, cmd @@ -340,8 +349,11 @@ type area interface { tea.Model SetCursor(c int) GetName() string + IsFiltering() bool } +type focusMsg struct{} + type areaPicker struct { common *common.Common list list.Model @@ -413,26 +425,54 @@ func (a *areaPicker) View() string { return a.list.View() } +type EditableField interface { + tea.Model + Focus() tea.Cmd + Blur() tea.Cmd +} + type taskEdit struct { common *common.Common - fields []huh.Field + fields []EditableField cursor int + projectPicker *picker.Picker // newProjectName *string newAnnotation *string udaValues map[string]*string } -func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { +func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit { // newProject := "" - projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...) if task.Project == "" { task.Project = "(none)" } + itemProvider := func() []list.Item { + projects := com.TW.GetProjects() + items := []list.Item{picker.NewItem("(none)")} + for _, proj := range projects { + items = append(items, picker.NewItem(proj)) + } + return items + } + onSelect := func(item list.Item) tea.Cmd { + return nil + } + + opts := []picker.PickerOption{} + if isNew { + opts = append(opts, picker.WithFilterByDefault(true)) + } + + projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...) + projPicker.SetSize(70, 8) + projPicker.SelectItemByFilterValue(task.Project) + projPicker.Blur() + defaultKeymap := huh.NewDefaultKeyMap() - fields := []huh.Field{ + fields := []EditableField{ huh.NewInput(). Title("Task"). Value(&task.Description). @@ -446,12 +486,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { Prompt(": "). WithTheme(com.Styles.Form), - input.NewSelect(com). - Options(true, input.NewOptions(projectOptions...)...). - Title("Project"). - Value(&task.Project). - WithKeyMap(defaultKeymap). - WithTheme(com.Styles.Form), + projPicker, // huh.NewInput(). // Title("New Project"). @@ -544,8 +579,9 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { WithTheme(com.Styles.Form)) t := taskEdit{ - common: com, - fields: fields, + common: com, + fields: fields, + projectPicker: projPicker, udaValues: udaValues, @@ -562,6 +598,13 @@ func (t *taskEdit) GetName() string { return "Task" } +func (t *taskEdit) IsFiltering() bool { + if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok { + return f.IsFiltering() + } + return false +} + func (t *taskEdit) SetCursor(c int) { t.fields[t.cursor].Blur() if c < 0 { @@ -573,11 +616,25 @@ func (t *taskEdit) SetCursor(c int) { } func (t *taskEdit) Init() tea.Cmd { - return nil + var cmds []tea.Cmd + // Ensure focus on the active field (especially for the first one) + if len(t.fields) > 0 { + cmds = append(cmds, func() tea.Msg { + return focusMsg{} + }) + } + for _, f := range t.fields { + cmds = append(cmds, f.Init()) + } + return tea.Batch(cmds...) } func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { + case focusMsg: + if len(t.fields) > 0 { + return t, t.fields[t.cursor].Focus() + } case nextFieldMsg: if t.cursor == len(t.fields)-1 { t.fields[t.cursor].Blur() @@ -596,7 +653,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.fields[t.cursor].Focus() default: field, cmd := t.fields[t.cursor].Update(msg) - t.fields[t.cursor] = field.(huh.Field) + t.fields[t.cursor] = field.(EditableField) return t, cmd } @@ -659,6 +716,13 @@ func (t *tagEdit) GetName() string { return "Tags" } +func (t *tagEdit) IsFiltering() bool { + if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok { + return f.IsFiltering() + } + return false +} + func (t *tagEdit) SetCursor(c int) { t.fields[t.cursor].Blur() if c < 0 { @@ -771,6 +835,10 @@ func (t *timeEdit) GetName() string { return "Dates" } +func (t *timeEdit) IsFiltering() bool { + return false +} + func (t *timeEdit) SetCursor(c int) { if len(t.fields) == 0 { return @@ -903,6 +971,10 @@ func (d *detailsEdit) GetName() string { return "Details" } +func (d *detailsEdit) IsFiltering() bool { + return false +} + func (d *detailsEdit) SetCursor(c int) { } @@ -1070,6 +1142,8 @@ func (d *detailsEdit) View() string { // } func (p *TaskEditorPage) updateTasksCmd() tea.Msg { + p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue() + if p.task.Project == "(none)" { p.task.Project = "" }