diff --git a/components/picker/picker.go b/components/picker/picker.go new file mode 100644 index 0000000..0b75f40 --- /dev/null +++ b/components/picker/picker.go @@ -0,0 +1,144 @@ +package picker + +import ( + "tasksquire/common" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Item struct { + text string +} + +func NewItem(text string) Item { return Item{text: text} } +func (i Item) Title() string { return i.text } +func (i Item) Description() string { return "" } +func (i Item) FilterValue() string { return i.text } + +type Picker struct { + common *common.Common + list list.Model + onSelect func(list.Item) tea.Cmd + title string + filterByDefault bool +} + +type PickerOption func(*Picker) + +func WithFilterByDefault(enabled bool) PickerOption { + return func(p *Picker) { + p.filterByDefault = enabled + } +} + +func New( + c *common.Common, + title string, + items []list.Item, + onSelect func(list.Item) tea.Cmd, + opts ...PickerOption, +) *Picker { + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(0) + + l := list.New(items, delegate, 0, 0) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + + // Custom key for filtering (insert mode) + l.KeyMap.Filter = key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "filter"), + ) + + p := &Picker{ + common: c, + list: l, + onSelect: onSelect, + title: title, + } + + if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { + p.filterByDefault = true + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +func (p *Picker) SetSize(width, height int) { + // We do NOT set common.SetSize here, as we are a sub-component. + + // Set list size. The parent is responsible for providing a reasonable size. + // If this component is intended to fill a page, width/height will be large. + // If it's a small embedded box, they will be small. + // We apply a small margin for the title if needed, but for now we just pass through + // minus a header gap if we render a title. + + headerHeight := 2 // Title + gap + p.list.SetSize(width, height-headerHeight) +} + +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) { + 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.onSelect(items[0]) + } + } + break // Pass to list.Update + } + + switch { + case key.Matches(msg, p.common.Keymap.Ok): + selectedItem := p.list.SelectedItem() + if selectedItem == nil { + return p, nil + } + return p, p.onSelect(selectedItem) + } + } + + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p *Picker) View() string { + title := p.common.Styles.Form.Focused.Title.Render(p.title) + return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View()) +} + +// SelectItemByFilterValue selects the item with the given filter value +func (p *Picker) SelectItemByFilterValue(filterValue string) { + items := p.list.Items() + for i, item := range items { + if item.FilterValue() == filterValue { + p.list.Select(i) + break + } + } +} diff --git a/pages/contextPicker.go b/pages/contextPicker.go index 9faf351..4d21c5e 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -4,18 +4,19 @@ import ( "log/slog" "slices" "tasksquire/common" + "tasksquire/components/picker" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) type ContextPickerPage struct { common *common.Common contexts taskwarrior.Contexts - form *huh.Form + picker *picker.Picker } func NewContextPickerPage(common *common.Common) *ContextPickerPage { @@ -34,19 +35,22 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { slices.Sort(options) options = append([]string{"(none)"}, options...) - p.form = huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Key("context"). - Options(huh.NewOptions(options...)...). - Title("Contexts"). - Description("Choose a context"). - Value(&selected). - WithTheme(common.Styles.Form), - ), - ). - WithShowHelp(false). - WithShowErrors(true) + items := []list.Item{} + for _, opt := range options { + items = append(items, picker.NewItem(opt)) + } + + onSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { return contextSelectedMsg{item: item} } + } + + p.picker = picker.New(common, "Contexts", items, onSelect) + + // Set active context + if selected == "" { + selected = "(none)" + } + p.picker.SelectItemByFilterValue(selected) p.SetSize(common.Width(), common.Height()) @@ -56,29 +60,46 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { func (p *ContextPickerPage) SetSize(width, height int) { p.common.SetSize(width, height) - if width >= 20 { - p.form = p.form.WithWidth(20) - } else { - p.form = p.form.WithWidth(width) + // Set list size with some padding/limits to look like a picker + listWidth := width - 4 + if listWidth > 40 { + listWidth = 40 } - - if height >= 30 { - p.form = p.form.WithHeight(30) - } else { - p.form = p.form.WithHeight(height) + listHeight := height - 6 + if listHeight > 20 { + listHeight = 20 } + p.picker.SetSize(listWidth, listHeight) } func (p *ContextPickerPage) Init() tea.Cmd { - return p.form.Init() + return p.picker.Init() +} + +type contextSelectedMsg struct { + item list.Item } func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd + var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) + case contextSelectedMsg: + name := msg.item.(picker.Item).Title() + if name == "(none)" { + name = "" + } + + ctx := p.common.TW.GetContext(name) + + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, func() tea.Msg { return UpdateContextMsg(ctx) } case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): @@ -91,41 +112,26 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - f, cmd := p.form.Update(msg) - if f, ok := f.(*huh.Form); ok { - p.form = f - cmds = append(cmds, cmd) - } - - if p.form.State == huh.StateCompleted { - cmds = append(cmds, p.updateContextCmd) - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, tea.Batch(cmds...) - } - - return p, tea.Batch(cmds...) + _, cmd = p.picker.Update(msg) + return p, cmd } func (p *ContextPickerPage) View() string { + width := p.common.Width() - 4 + if width > 40 { + width = 40 + } + + content := p.picker.View() + styledContent := lipgloss.NewStyle().Width(width).Render(content) + return lipgloss.Place( p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, - p.common.Styles.Base.Render(p.form.View()), + p.common.Styles.Base.Render(styledContent), ) } -func (p *ContextPickerPage) updateContextCmd() tea.Msg { - context := p.form.GetString("context") - if context == "(none)" { - context = "" - } - return UpdateContextMsg(p.common.TW.GetContext(context)) -} - type UpdateContextMsg *taskwarrior.Context diff --git a/pages/projectPicker.go b/pages/projectPicker.go index 472c012..0723f5e 100644 --- a/pages/projectPicker.go +++ b/pages/projectPicker.go @@ -3,16 +3,17 @@ package pages import ( "log/slog" "tasksquire/common" + "tasksquire/components/picker" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) type ProjectPickerPage struct { common *common.Common - form *huh.Form + picker *picker.Picker } func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage { @@ -20,30 +21,23 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP common: common, } - var selected string - if activeProject == "" { - selected = "(none)" - } else { - selected = activeProject + projects := common.TW.GetProjects() + items := []list.Item{picker.NewItem("(none)")} + for _, proj := range projects { + items = append(items, picker.NewItem(proj)) } - projects := common.TW.GetProjects() - options := []string{"(none)"} - options = append(options, projects...) + onSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { return projectSelectedMsg{item: item} } + } - p.form = huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Key("project"). - Options(huh.NewOptions(options...)...). - Title("Projects"). - Description("Choose a project"). - Value(&selected). - WithTheme(common.Styles.Form), - ), - ). - WithShowHelp(false). - WithShowErrors(false) + p.picker = picker.New(common, "Projects", items, onSelect) + + // Set active project + if activeProject == "" { + activeProject = "(none)" + } + p.picker.SelectItemByFilterValue(activeProject) p.SetSize(common.Width(), common.Height()) @@ -53,29 +47,44 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP func (p *ProjectPickerPage) SetSize(width, height int) { p.common.SetSize(width, height) - if width >= 20 { - p.form = p.form.WithWidth(20) - } else { - p.form = p.form.WithWidth(width) + // Set list size with some padding/limits to look like a picker + listWidth := width - 4 + if listWidth > 40 { + listWidth = 40 } - - if height >= 30 { - p.form = p.form.WithHeight(30) - } else { - p.form = p.form.WithHeight(height) + listHeight := height - 6 + if listHeight > 20 { + listHeight = 20 } + p.picker.SetSize(listWidth, listHeight) } func (p *ProjectPickerPage) Init() tea.Cmd { - return p.form.Init() + return p.picker.Init() +} + +type projectSelectedMsg struct { + item list.Item } func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd + var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) + case projectSelectedMsg: + proj := msg.item.(picker.Item).Title() + if proj == "(none)" { + proj = "" + } + + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, func() tea.Msg { return UpdateProjectMsg(proj) } case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): @@ -88,41 +97,30 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - f, cmd := p.form.Update(msg) - if f, ok := f.(*huh.Form); ok { - p.form = f - cmds = append(cmds, cmd) - } - - if p.form.State == huh.StateCompleted { - cmds = append(cmds, p.updateProjectCmd) - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, tea.Batch(cmds...) - } - - return p, tea.Batch(cmds...) + _, cmd = p.picker.Update(msg) + return p, cmd } func (p *ProjectPickerPage) View() string { + width := p.common.Width() - 4 + if width > 40 { + width = 40 + } + + content := p.picker.View() + styledContent := lipgloss.NewStyle().Width(width).Render(content) + return lipgloss.Place( p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, - p.common.Styles.Base.Render(p.form.View()), + p.common.Styles.Base.Render(styledContent), ) } func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { - project := p.form.GetString("project") - if project == "(none)" { - project = "" - } - return UpdateProjectMsg(project) + return nil } type UpdateProjectMsg string diff --git a/pages/report.go b/pages/report.go index a9d6b31..a12ef19 100644 --- a/pages/report.go +++ b/pages/report.go @@ -94,24 +94,24 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Quit case key.Matches(msg, p.common.Keymap.SetReport): p.subpage = NewReportPickerPage(p.common, p.activeReport) - p.subpage.Init() + cmd := p.subpage.Init() p.common.PushPage(p) - return p.subpage, nil + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.SetContext): p.subpage = NewContextPickerPage(p.common) - p.subpage.Init() + cmd := p.subpage.Init() p.common.PushPage(p) - return p.subpage, nil + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.Add): p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask()) - p.subpage.Init() + cmd := p.subpage.Init() p.common.PushPage(p) - return p.subpage, nil + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.Edit): p.subpage = NewTaskEditorPage(p.common, *p.selectedTask) - p.subpage.Init() + cmd := p.subpage.Init() p.common.PushPage(p) - return p.subpage, nil + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.Ok): p.common.TW.SetTaskDone(p.selectedTask) return p, p.getTasks() @@ -120,9 +120,9 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, p.getTasks() case key.Matches(msg, p.common.Keymap.SetProject): p.subpage = NewProjectPickerPage(p.common, p.activeProject) - p.subpage.Init() + cmd := p.subpage.Init() p.common.PushPage(p) - return p.subpage, nil + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.Tag): if p.selectedTask != nil { tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default") diff --git a/taskwarrior/config.go b/taskwarrior/config.go index e71718d..c77c3ac 100644 --- a/taskwarrior/config.go +++ b/taskwarrior/config.go @@ -12,9 +12,10 @@ type TWConfig struct { var ( defaultConfig = map[string]string{ - "uda.tasksquire.report.default": "next", - "uda.tasksquire.tag.default": "next", - "uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research", + "uda.tasksquire.report.default": "next", + "uda.tasksquire.tag.default": "next", + "uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research", + "uda.tasksquire.picker.filter_by_default": "yes", } )