From 0d55a3b119685cef05b463804821b9c33b598f5b Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 11 Jun 2024 21:48:13 +0200 Subject: [PATCH] Add new options in multiselect --- common/keymap.go | 12 + components/input/multiselect.go | 666 ++++++++++++++++++++++++++++++++ components/input/option.go | 38 ++ go.mod | 33 +- go.sum | 76 ++-- pages/taskEditor.go | 51 ++- test/taskchampion.sqlite3 | Bin 253952 -> 258048 bytes 7 files changed, 811 insertions(+), 65 deletions(-) create mode 100644 components/input/multiselect.go create mode 100644 components/input/option.go diff --git a/common/keymap.go b/common/keymap.go index 887534d..e50be19 100644 --- a/common/keymap.go +++ b/common/keymap.go @@ -19,6 +19,8 @@ type Keymap struct { Right key.Binding Next key.Binding Prev key.Binding + NextPage key.Binding + PrevPage key.Binding SetReport key.Binding SetContext key.Binding SetProject key.Binding @@ -98,6 +100,16 @@ func NewKeymap() *Keymap { key.WithHelp("shift+tab", "Previous"), ), + NextPage: key.NewBinding( + key.WithKeys("]"), + key.WithHelp("[", "Next page"), + ), + + PrevPage: key.NewBinding( + key.WithKeys("["), + key.WithHelp("]", "Previous page"), + ), + SetReport: key.NewBinding( key.WithKeys("r"), key.WithHelp("r", "Set report"), diff --git a/components/input/multiselect.go b/components/input/multiselect.go new file mode 100644 index 0000000..e0a2e7e --- /dev/null +++ b/components/input/multiselect.go @@ -0,0 +1,666 @@ +package input + +import ( + "fmt" + "strings" + "tasksquire/common" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/huh/accessibility" + "github.com/charmbracelet/lipgloss" +) + +// MultiSelect is a form multi-select field. +type MultiSelect struct { + common *common.Common + + value *[]string + key string + + // customization + title string + description string + options []Option[string] + filterable bool + filteredOptions []Option[string] + limit int + height int + + // error handling + validate func([]string) error + err error + + // state + cursor int + focused bool + filtering bool + filter textinput.Model + viewport viewport.Model + + // options + width int + accessible bool + theme *huh.Theme + keymap huh.MultiSelectKeyMap + + // new + hasNewOption bool + newInput textinput.Model + newInputActive bool +} + +// NewMultiSelect returns a new multi-select field. +func NewMultiSelect(common *common.Common) *MultiSelect { + filter := textinput.New() + filter.Prompt = "/" + + newInput := textinput.New() + newInput.Prompt = "New: " + + return &MultiSelect{ + common: common, + options: []Option[string]{}, + value: new([]string), + validate: func([]string) error { return nil }, + filtering: false, + filter: filter, + newInput: newInput, + newInputActive: false, + } +} + +// Value sets the value of the multi-select field. +func (m *MultiSelect) Value(value *[]string) *MultiSelect { + m.value = value + for i, o := range m.options { + for _, v := range *value { + if o.Value == v { + m.options[i].selected = true + break + } + } + } + return m +} + +// Key sets the key of the select field which can be used to retrieve the value +// after submission. +func (m *MultiSelect) Key(key string) *MultiSelect { + m.key = key + return m +} + +// Title sets the title of the multi-select field. +func (m *MultiSelect) Title(title string) *MultiSelect { + m.title = title + return m +} + +// Description sets the description of the multi-select field. +func (m *MultiSelect) Description(description string) *MultiSelect { + m.description = description + return m +} + +// Options sets the options of the multi-select field. +func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect { + if len(options) <= 0 { + return m + } + + m.hasNewOption = hasNewOption + + if m.hasNewOption { + newOption := []Option[string]{ + {Key: "(new)", Value: ""}, + } + options = append(newOption, options...) + } + + for i, o := range options { + for _, v := range *m.value { + if o.Value == v { + options[i].selected = true + break + } + } + } + m.options = options + m.filteredOptions = options + m.updateViewportHeight() + return m +} + +// Filterable sets the multi-select field as filterable. +func (m *MultiSelect) Filterable(filterable bool) *MultiSelect { + m.filterable = filterable + return m +} + +// Limit sets the limit of the multi-select field. +func (m *MultiSelect) Limit(limit int) *MultiSelect { + m.limit = limit + return m +} + +// Height sets the height of the multi-select field. +func (m *MultiSelect) Height(height int) *MultiSelect { + // What we really want to do is set the height of the viewport, but we + // need a theme applied before we can calcualate its height. + m.height = height + m.updateViewportHeight() + return m +} + +// Validate sets the validation function of the multi-select field. +func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect { + m.validate = validate + return m +} + +// Error returns the error of the multi-select field. +func (m *MultiSelect) Error() error { + return m.err +} + +// Skip returns whether the multiselect should be skipped or should be blocking. +func (*MultiSelect) Skip() bool { + return false +} + +// Zoom returns whether the multiselect should be zoomed. +func (*MultiSelect) Zoom() bool { + return false +} + +// Focus focuses the multi-select field. +func (m *MultiSelect) Focus() tea.Cmd { + m.focused = true + return nil +} + +// Blur blurs the multi-select field. +func (m *MultiSelect) Blur() tea.Cmd { + m.focused = false + return nil +} + +// KeyBinds returns the help message for the multi-select field. +func (m *MultiSelect) KeyBinds() []key.Binding { + return []key.Binding{ + m.keymap.Toggle, + m.keymap.Up, + m.keymap.Down, + m.keymap.Filter, + m.keymap.SetFilter, + m.keymap.ClearFilter, + m.keymap.Prev, + m.keymap.Submit, + m.keymap.Next, + } +} + +// Init initializes the multi-select field. +func (m *MultiSelect) Init() tea.Cmd { + return nil +} + +// Update updates the multi-select field. +func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Enforce height on the viewport during update as we need themes to + // be applied before we can calculate the height. + m.updateViewportHeight() + + var cmd tea.Cmd + if m.filtering { + m.filter, cmd = m.filter.Update(msg) + } + + if m.newInputActive { + m.newInput, cmd = m.newInput.Update(msg) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.common.Keymap.Ok): + newOptions := []Option[string]{} + for _, item := range strings.Split(m.newInput.Value(), " ") { + newOptions = append(newOptions, Option[string]{ + Key: item, + Value: item, + selected: true, + }) + } + m.options = append(m.options, newOptions...) + filteredNewOptions := []Option[string]{} + for _, item := range newOptions { + if m.filterFunc(item.Key) { + filteredNewOptions = append(filteredNewOptions, item) + } + } + m.filteredOptions = append(m.filteredOptions, filteredNewOptions...) + m.newInputActive = false + m.newInput.SetValue("") + m.newInput.Blur() + case key.Matches(msg, m.common.Keymap.Back): + m.newInputActive = false + m.newInput.Blur() + return m, SuppressBack() + } + } + } + + switch msg := msg.(type) { + case tea.KeyMsg: + + m.err = nil + + switch { + case key.Matches(msg, m.keymap.Filter): + m.setFilter(true) + return m, m.filter.Focus() + case key.Matches(msg, m.keymap.SetFilter) && m.filtering: + if len(m.filteredOptions) <= 0 { + m.filter.SetValue("") + m.filteredOptions = m.options + } + m.setFilter(false) + case key.Matches(msg, m.common.Keymap.Back) && m.filtering: + m.filter.SetValue("") + m.filteredOptions = m.options + m.setFilter(false) + case key.Matches(msg, m.keymap.ClearFilter): + m.filter.SetValue("") + m.filteredOptions = m.options + m.setFilter(false) + case key.Matches(msg, m.keymap.Up): + if m.filtering && msg.String() == "k" { + break + } + + m.cursor = max(m.cursor-1, 0) + if m.cursor < m.viewport.YOffset { + m.viewport.SetYOffset(m.cursor) + } + case key.Matches(msg, m.keymap.Down): + if m.filtering && msg.String() == "j" { + break + } + + m.cursor = min(m.cursor+1, len(m.filteredOptions)-1) + if m.cursor >= m.viewport.YOffset+m.viewport.Height { + m.viewport.LineDown(1) + } + case key.Matches(msg, m.keymap.GotoTop): + if m.filtering { + break + } + m.cursor = 0 + m.viewport.GotoTop() + case key.Matches(msg, m.keymap.GotoBottom): + if m.filtering { + break + } + m.cursor = len(m.filteredOptions) - 1 + m.viewport.GotoBottom() + case key.Matches(msg, m.keymap.HalfPageUp): + m.cursor = max(m.cursor-m.viewport.Height/2, 0) + m.viewport.HalfViewUp() + case key.Matches(msg, m.keymap.HalfPageDown): + m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1) + m.viewport.HalfViewDown() + case key.Matches(msg, m.keymap.Toggle) && !m.filtering: + if m.hasNewOption && m.cursor == 0 { + m.newInputActive = true + m.newInput.Focus() + } else { + for i, option := range m.options { + if option.Key == m.filteredOptions[m.cursor].Key { + if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { + break + } + selected := m.options[i].selected + m.options[i].selected = !selected + m.filteredOptions[m.cursor].selected = !selected + } + } + } + case key.Matches(msg, m.keymap.Prev): + m.finalize() + if m.err != nil { + return m, nil + } + return m, huh.PrevField + case key.Matches(msg, m.keymap.Next, m.keymap.Submit): + m.finalize() + if m.err != nil { + return m, nil + } + return m, huh.NextField + } + + if m.filtering { + m.filteredOptions = m.options + if m.filter.Value() != "" { + m.filteredOptions = nil + for _, option := range m.options { + if m.filterFunc(option.Key) { + m.filteredOptions = append(m.filteredOptions, option) + } + } + } + if len(m.filteredOptions) > 0 { + m.cursor = min(m.cursor, len(m.filteredOptions)-1) + m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height)) + } + } + } + + return m, cmd +} + +// updateViewportHeight updates the viewport size according to the Height setting +// on this multi-select field. +func (m *MultiSelect) updateViewportHeight() { + // If no height is set size the viewport to the number of options. + if m.height <= 0 { + m.viewport.Height = len(m.options) + return + } + + const minHeight = 1 + m.viewport.Height = max(minHeight, m.height- + lipgloss.Height(m.titleView())- + lipgloss.Height(m.descriptionView())) +} + +func (m *MultiSelect) numSelected() int { + var count int + for _, o := range m.options { + if o.selected { + count++ + } + } + return count +} + +func (m *MultiSelect) finalize() { + *m.value = make([]string, 0) + for _, option := range m.options { + if option.selected { + *m.value = append(*m.value, option.Value) + } + } + m.err = m.validate(*m.value) +} + +func (m *MultiSelect) activeStyles() *huh.FieldStyles { + theme := m.theme + if theme == nil { + theme = huh.ThemeCharm() + } + if m.focused { + return &theme.Focused + } + return &theme.Blurred +} + +func (m *MultiSelect) titleView() string { + if m.title == "" { + return "" + } + var ( + styles = m.activeStyles() + sb = strings.Builder{} + ) + if m.filtering { + sb.WriteString(m.filter.View()) + } else if m.filter.Value() != "" { + sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value())) + } else { + sb.WriteString(styles.Title.Render(m.title)) + } + if m.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + } + return sb.String() +} + +func (m *MultiSelect) descriptionView() string { + return m.activeStyles().Description.Render(m.description) +} + +func (m *MultiSelect) choicesView() string { + var ( + styles = m.activeStyles() + c = styles.MultiSelectSelector.String() + sb strings.Builder + ) + for i, option := range m.filteredOptions { + if m.newInputActive && i == 0 { + sb.WriteString(c) + sb.WriteString(m.newInput.View()) + sb.WriteString("\n") + continue + } else if m.cursor == i { + sb.WriteString(c) + } else { + sb.WriteString(strings.Repeat(" ", lipgloss.Width(c))) + } + + if m.filteredOptions[i].selected { + sb.WriteString(styles.SelectedPrefix.String()) + sb.WriteString(styles.SelectedOption.Render(option.Key)) + } else { + sb.WriteString(styles.UnselectedPrefix.String()) + sb.WriteString(styles.UnselectedOption.Render(option.Key)) + } + if i < len(m.options)-1 { + sb.WriteString("\n") + } + } + + for i := len(m.filteredOptions); i < len(m.options)-1; i++ { + sb.WriteString("\n") + } + + return sb.String() +} + +// View renders the multi-select field. +func (m *MultiSelect) View() string { + styles := m.activeStyles() + m.viewport.SetContent(m.choicesView()) + + var sb strings.Builder + if m.title != "" { + sb.WriteString(m.titleView()) + sb.WriteString("\n") + } + if m.description != "" { + sb.WriteString(m.descriptionView() + "\n") + } + sb.WriteString(m.viewport.View()) + return styles.Base.Render(sb.String()) +} + +func (m *MultiSelect) printOptions() { + styles := m.activeStyles() + var sb strings.Builder + + sb.WriteString(styles.Title.Render(m.title)) + sb.WriteString("\n") + + for i, option := range m.options { + if option.selected { + sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key))) + } else { + sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key)) + } + sb.WriteString("\n") + } + + fmt.Println(sb.String()) +} + +// setFilter sets the filter of the select field. +func (m *MultiSelect) setFilter(filter bool) { + m.filtering = filter + m.keymap.SetFilter.SetEnabled(filter) + m.keymap.Filter.SetEnabled(!filter) + m.keymap.Next.SetEnabled(!filter) + m.keymap.Submit.SetEnabled(!filter) + m.keymap.Prev.SetEnabled(!filter) + m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "") +} + +// filterFunc returns true if the option matches the filter. +func (m *MultiSelect) filterFunc(option string) bool { + // XXX: remove diacritics or allow customization of filter function. + return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value())) +} + +// Run runs the multi-select field. +func (m *MultiSelect) Run() error { + if m.accessible { + return m.runAccessible() + } + return huh.Run(m) +} + +// runAccessible() runs the multi-select field in accessible mode. +func (m *MultiSelect) runAccessible() error { + m.printOptions() + styles := m.activeStyles() + + var choice int + for { + fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit) + + choice = accessibility.PromptInt("Select: ", 0, len(m.options)) + if choice == 0 { + m.finalize() + err := m.validate(*m.value) + if err != nil { + fmt.Println(err) + continue + } + break + } + + if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit { + fmt.Printf("You can't select more than %d options.\n", m.limit) + continue + } + m.options[choice-1].selected = !m.options[choice-1].selected + if m.options[choice-1].selected { + fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key) + } else { + fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key) + } + + m.printOptions() + } + + var values []string + + for _, option := range m.options { + if option.selected { + *m.value = append(*m.value, option.Value) + values = append(values, option.Key) + } + } + + fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n")) + return nil +} + +// WithTheme sets the theme of the multi-select field. +func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field { + if m.theme != nil { + return m + } + m.theme = theme + m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor + m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt + m.updateViewportHeight() + return m +} + +// WithKeyMap sets the keymap of the multi-select field. +func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field { + m.keymap = k.MultiSelect + return m +} + +// WithAccessible sets the accessible mode of the multi-select field. +func (m *MultiSelect) WithAccessible(accessible bool) huh.Field { + m.accessible = accessible + return m +} + +// WithWidth sets the width of the multi-select field. +func (m *MultiSelect) WithWidth(width int) huh.Field { + m.width = width + return m +} + +// WithHeight sets the height of the multi-select field. +func (m *MultiSelect) WithHeight(height int) huh.Field { + m.height = height + return m +} + +// WithPosition sets the position of the multi-select field. +func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field { + if m.filtering { + return m + } + m.keymap.Prev.SetEnabled(!p.IsFirst()) + m.keymap.Next.SetEnabled(!p.IsLast()) + m.keymap.Submit.SetEnabled(p.IsLast()) + return m +} + +// GetKey returns the multi-select's key. +func (m *MultiSelect) GetKey() string { + return m.key +} + +// GetValue returns the multi-select's value. +func (m *MultiSelect) GetValue() any { + return *m.value +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func clamp(n, low, high int) int { + if low > high { + low, high = high, low + } + return min(high, max(low, n)) +} + +type SuppressBackMsg struct{} + +func SuppressBack() tea.Cmd { + return func() tea.Msg { + return SuppressBackMsg{} + } +} diff --git a/components/input/option.go b/components/input/option.go new file mode 100644 index 0000000..caf5be6 --- /dev/null +++ b/components/input/option.go @@ -0,0 +1,38 @@ +package input + +import "fmt" + +// Option is an option for select fields. +type Option[T comparable] struct { + Key string + Value T + selected bool +} + +// NewOptions returns new options from a list of values. +func NewOptions[T comparable](values ...T) []Option[T] { + options := make([]Option[T], len(values)) + for i, o := range values { + options[i] = Option[T]{ + Key: fmt.Sprint(o), + Value: o, + } + } + return options +} + +// NewOption returns a new select option. +func NewOption[T comparable](key string, value T) Option[T] { + return Option[T]{Key: key, Value: value} +} + +// Selected sets whether the option is currently selected. +func (o Option[T]) Selected(selected bool) Option[T] { + o.selected = selected + return o +} + +// String returns the key of the option. +func (o Option[T]) String() string { + return o.Key +} diff --git a/go.mod b/go.mod index af473f5..434e77c 100644 --- a/go.mod +++ b/go.mod @@ -4,39 +4,36 @@ go 1.22.2 require ( github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.1 - github.com/charmbracelet/glamour v0.7.0 - github.com/charmbracelet/huh v0.3.0 - github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb + github.com/charmbracelet/bubbletea v0.26.4 + github.com/charmbracelet/huh v0.4.2 + github.com/charmbracelet/lipgloss v0.11.0 github.com/mattn/go-runewidth v0.0.15 - golang.org/x/term v0.20.0 + golang.org/x/term v0.21.0 ) require ( - github.com/alecthomas/chroma/v2 v2.8.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect - github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect + github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect + github.com/charmbracelet/x/input v0.1.1 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gorilla/css v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect - github.com/yuin/goldmark v1.5.4 // indirect - github.com/yuin/goldmark-emoji v1.0.2 // indirect - golang.org/x/net v0.17.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 02d4238..b80ee42 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,33 @@ -github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= -github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= -github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= -github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= -github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= -github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= -github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= -github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= -github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ= -github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= -github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA= -github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= +github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= +github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM= +github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= +github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I= +github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY= +github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0= +github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= +github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= +github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -40,12 +36,9 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= -github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -54,28 +47,23 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= -github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/pages/taskEditor.go b/pages/taskEditor.go index fba7185..db40bc3 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -7,6 +7,7 @@ import ( "tasksquire/common" "time" + "tasksquire/components/input" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" @@ -151,6 +152,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return nil, tea.Quit } return model, p.updateTasksCmd + case key.Matches(msg, p.common.Keymap.PrevPage): + return p, prevArea() + case key.Matches(msg, p.common.Keymap.NextPage): + return p, nextArea() case key.Matches(msg, p.common.Keymap.Left): return p, prevColumn() case key.Matches(msg, p.common.Keymap.Right): @@ -192,6 +197,14 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): + model, cmd := p.areas[p.area].Update(msg) + p.areas[p.area] = model.(area) + if cmd != nil { + _, ok := cmd().(input.SuppressBackMsg) + if ok { + return p, nil + } + } return p, changeMode(modeNormal) case key.Matches(msg, p.common.Keymap.Prev): if p.columnCursor == 0 { @@ -264,17 +277,33 @@ func (p *TaskEditorPage) View() string { } + tabs := "" + for i, a := range p.areas { + if i == p.area { + tabs += p.common.Styles.Base.Bold(true).Render(fmt.Sprintf(" %s ", a.GetName())) + } else { + tabs += p.common.Styles.Base.Render(fmt.Sprintf(" %s ", a.GetName())) + } + } + + page := lipgloss.JoinVertical( + lipgloss.Left, + tabs, + area, + ) + // return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, lipgloss.JoinHorizontal( // lipgloss.Center, // picker, // area, // )) - return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, area) + return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, page) } type area interface { tea.Model SetCursor(c int) + GetName() string } type areaPicker struct { @@ -493,6 +522,10 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { return &t } +func (t *taskEdit) GetName() string { + return "Task" +} + func (t *taskEdit) SetCursor(c int) { t.fields[t.cursor].Blur() if c < 0 { @@ -565,8 +598,8 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta t := tagEdit{ common: common, fields: []huh.Field{ - huh.NewMultiSelect[string](). - Options(huh.NewOptions(options...)...). + input.NewMultiSelect(common). + Options(true, input.NewOptions(options...)...). // Key("tags"). Title("Tags"). Value(selected). @@ -586,6 +619,10 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta return &t } +func (t *tagEdit) GetName() string { + return "Tags" +} + func (t *tagEdit) SetCursor(c int) { t.fields[t.cursor].Blur() if c < 0 { @@ -683,6 +720,10 @@ func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *st return &t } +func (t *timeEdit) GetName() string { + return "Dates" +} + func (t *timeEdit) SetCursor(c int) { t.fields[t.cursor].Blur() if c < 0 { @@ -772,6 +813,10 @@ func NewDetailsEdit(com *common.Common, task *taskwarrior.Task) *detailsEdit { return &d } +func (d *detailsEdit) GetName() string { + return "Details" +} + func (d *detailsEdit) SetCursor(c int) { } diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index 21cdc25aee627e6da98422890e052e642000333c..5a11a007366a47c0b3437b73e7da22754570dab2 100644 GIT binary patch delta 5645 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 Utb|AtzEnYHaymG!L5wf`2k6-d^8f$<