From 73d51b956aa4dbdbacbbfd5df3f23f1ca8a517c8 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 24 Jun 2024 16:36:11 +0200 Subject: [PATCH] Fix pickers; Add new select option --- common/keymap.go | 4 +- components/input/multiselect.go | 9 +- components/input/select.go | 618 ++++++++++++++++++++++++++++++++ pages/contextPicker.go | 29 +- pages/projectPicker.go | 26 +- pages/reportPicker.go | 26 +- pages/taskEditor.go | 34 +- test/taskchampion.sqlite3 | Bin 266240 -> 278528 bytes 8 files changed, 715 insertions(+), 31 deletions(-) create mode 100644 components/input/select.go diff --git a/common/keymap.go b/common/keymap.go index e50be19..806e82f 100644 --- a/common/keymap.go +++ b/common/keymap.go @@ -126,8 +126,8 @@ func NewKeymap() *Keymap { ), Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "Select"), + key.WithKeys(" "), + key.WithHelp("space", "Select"), ), Insert: key.NewBinding( diff --git a/components/input/multiselect.go b/components/input/multiselect.go index e0a2e7e..b54586f 100644 --- a/components/input/multiselect.go +++ b/components/input/multiselect.go @@ -108,10 +108,6 @@ func (m *MultiSelect) Description(description string) *MultiSelect { // 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 { @@ -121,6 +117,10 @@ func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *Mul options = append(newOption, options...) } + if len(options) <= 0 { + return m + } + for i, o := range options { for _, v := range *m.value { if o.Value == v { @@ -326,6 +326,7 @@ func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selected := m.options[i].selected m.options[i].selected = !selected m.filteredOptions[m.cursor].selected = !selected + m.finalize() } } } diff --git a/components/input/select.go b/components/input/select.go new file mode 100644 index 0000000..5685a34 --- /dev/null +++ b/components/input/select.go @@ -0,0 +1,618 @@ +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" +) + +// Select is a form select field. +type Select struct { + common *common.Common + + value *string + key string + viewport viewport.Model + + // customization + title string + description string + options []Option[string] + filteredOptions []Option[string] + height int + + // error handling + validate func(string) error + err error + + // state + selected int + focused bool + filtering bool + filter textinput.Model + + // options + inline bool + width int + accessible bool + theme *huh.Theme + keymap huh.SelectKeyMap + + // new + hasNewOption bool + newInput textinput.Model + newInputActive bool +} + +// NewSelect returns a new select field. +func NewSelect(com *common.Common) *Select { + filter := textinput.New() + filter.Prompt = "/" + + newInput := textinput.New() + newInput.Prompt = "New: " + + return &Select{ + common: com, + 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 select field. +func (s *Select) Value(value *string) *Select { + s.value = value + s.selectValue(*value) + return s +} + +func (s *Select) selectValue(value string) { + for i, o := range s.options { + if o.Value == value { + s.selected = i + break + } + } +} + +// Key sets the key of the select field which can be used to retrieve the value +// after submission. +func (s *Select) Key(key string) *Select { + s.key = key + return s +} + +// Title sets the title of the select field. +func (s *Select) Title(title string) *Select { + s.title = title + return s +} + +// Description sets the description of the select field. +func (s *Select) Description(description string) *Select { + s.description = description + return s +} + +// Options sets the options of the select field. +func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select { + s.hasNewOption = hasNewOption + + if s.hasNewOption { + newOption := []Option[string]{ + {Key: "(new)", Value: ""}, + } + options = append(newOption, options...) + } + + if len(options) <= 0 { + return s + } + s.options = options + s.filteredOptions = options + + // Set the cursor to the existing value or the last selected option. + for i, option := range options { + if option.Value == *s.value { + s.selected = i + break + } else if option.selected { + s.selected = i + } + } + + s.updateViewportHeight() + + return s +} + +// Inline sets whether the select input should be inline. +func (s *Select) Inline(v bool) *Select { + s.inline = v + if v { + s.Height(1) + } + s.keymap.Left.SetEnabled(v) + s.keymap.Right.SetEnabled(v) + s.keymap.Up.SetEnabled(!v) + s.keymap.Down.SetEnabled(!v) + return s +} + +// Height sets the height of the select field. If the number of options +// exceeds the height, the select field will become scrollable. +func (s *Select) Height(height int) *Select { + s.height = height + s.updateViewportHeight() + return s +} + +// Validate sets the validation function of the select field. +func (s *Select) Validate(validate func(string) error) *Select { + s.validate = validate + return s +} + +// Error returns the error of the select field. +func (s *Select) Error() error { + return s.err +} + +// Skip returns whether the select should be skipped or should be blocking. +func (*Select) Skip() bool { + return false +} + +// Zoom returns whether the input should be zoomed. +func (*Select) Zoom() bool { + return false +} + +// Focus focuses the select field. +func (s *Select) Focus() tea.Cmd { + s.focused = true + return nil +} + +// Blur blurs the select field. +func (s *Select) Blur() tea.Cmd { + value := *s.value + if s.inline { + s.clearFilter() + s.selectValue(value) + } + s.focused = false + s.err = s.validate(value) + return nil +} + +// KeyBinds returns the help keybindings for the select field. +func (s *Select) KeyBinds() []key.Binding { + return []key.Binding{ + s.keymap.Up, + s.keymap.Down, + s.keymap.Left, + s.keymap.Right, + s.keymap.Filter, + s.keymap.SetFilter, + s.keymap.ClearFilter, + s.keymap.Prev, + s.keymap.Next, + s.keymap.Submit, + } +} + +// Init initializes the select field. +func (s *Select) Init() tea.Cmd { + return nil +} + +// Update updates the select field. +func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + s.updateViewportHeight() + + var cmd tea.Cmd + if s.filtering { + s.filter, cmd = s.filter.Update(msg) + + // Keep the selected item in view. + if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height { + s.viewport.SetYOffset(s.selected) + } + } + + if s.newInputActive { + s.newInput, cmd = s.newInput.Update(msg) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, s.common.Keymap.Ok): + if s.newInput.Value() != "" { + newOption := Option[string]{ + Key: s.newInput.Value(), + Value: s.newInput.Value(), + selected: true, + } + s.options = append(s.options, newOption) + if s.filterFunc(newOption.Key) { + s.filteredOptions = append(s.filteredOptions, newOption) + } + s.selected = len(s.options) - 1 + + value := newOption.Value + s.setFiltering(false) + s.err = s.validate(value) + if s.err != nil { + return s, nil + } + *s.value = value + } + s.newInputActive = false + s.newInput.SetValue("") + s.newInput.Blur() + return s, nil + case key.Matches(msg, s.common.Keymap.Back): + s.newInputActive = false + s.newInput.Blur() + return s, SuppressBack() + } + } + } + + switch msg := msg.(type) { + case tea.KeyMsg: + s.err = nil + switch { + case key.Matches(msg, s.keymap.Filter): + s.setFiltering(true) + return s, s.filter.Focus() + case key.Matches(msg, s.keymap.SetFilter): + if len(s.filteredOptions) <= 0 { + s.filter.SetValue("") + s.filteredOptions = s.options + } + s.setFiltering(false) + case key.Matches(msg, s.keymap.ClearFilter): + s.clearFilter() + case key.Matches(msg, s.keymap.Up, s.keymap.Left): + // When filtering we should ignore j/k keybindings + // + // XXX: Currently, the below check doesn't account for keymap + // changes. When making this fix it's worth considering ignoring + // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 + // may not be enough when international keyboards are considered. + if s.filtering && (msg.String() == "k" || msg.String() == "h") { + break + } + s.selected = max(s.selected-1, 0) + if s.selected < s.viewport.YOffset { + s.viewport.SetYOffset(s.selected) + } + case key.Matches(msg, s.keymap.GotoTop): + if s.filtering { + break + } + s.selected = 0 + s.viewport.GotoTop() + case key.Matches(msg, s.keymap.GotoBottom): + if s.filtering { + break + } + s.selected = len(s.filteredOptions) - 1 + s.viewport.GotoBottom() + case key.Matches(msg, s.keymap.HalfPageUp): + s.selected = max(s.selected-s.viewport.Height/2, 0) + s.viewport.HalfViewUp() + case key.Matches(msg, s.keymap.HalfPageDown): + s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1) + s.viewport.HalfViewDown() + case key.Matches(msg, s.keymap.Down, s.keymap.Right): + // When filtering we should ignore j/k keybindings + // + // XXX: See note in the previous case match. + if s.filtering && (msg.String() == "j" || msg.String() == "l") { + break + } + s.selected = min(s.selected+1, len(s.filteredOptions)-1) + if s.selected >= s.viewport.YOffset+s.viewport.Height { + s.viewport.LineDown(1) + } + case key.Matches(msg, s.keymap.Prev): + if s.selected >= len(s.filteredOptions) { + break + } + value := s.filteredOptions[s.selected].Value + s.err = s.validate(value) + if s.err != nil { + return s, nil + } + *s.value = value + return s, huh.PrevField + case key.Matches(msg, s.common.Keymap.Select): + if s.hasNewOption && s.selected == 0 { + s.newInputActive = true + s.newInput.Focus() + return s, nil + } + case key.Matches(msg, s.keymap.Next, s.keymap.Submit): + if s.selected >= len(s.filteredOptions) { + break + } + value := s.filteredOptions[s.selected].Value + s.setFiltering(false) + s.err = s.validate(value) + if s.err != nil { + return s, nil + } + *s.value = value + return s, huh.NextField + } + + if s.filtering { + s.filteredOptions = s.options + if s.filter.Value() != "" { + s.filteredOptions = nil + for _, option := range s.options { + if s.filterFunc(option.Key) { + s.filteredOptions = append(s.filteredOptions, option) + } + } + } + if len(s.filteredOptions) > 0 { + s.selected = min(s.selected, len(s.filteredOptions)-1) + s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height)) + } + } + } + + return s, cmd +} + +// updateViewportHeight updates the viewport size according to the Height setting +// on this select field. +func (s *Select) updateViewportHeight() { + // If no height is set size the viewport to the number of options. + if s.height <= 0 { + s.viewport.Height = len(s.options) + return + } + + const minHeight = 1 + s.viewport.Height = max(minHeight, s.height- + lipgloss.Height(s.titleView())- + lipgloss.Height(s.descriptionView())) +} + +func (s *Select) activeStyles() *huh.FieldStyles { + theme := s.theme + if theme == nil { + theme = huh.ThemeCharm() + } + if s.focused { + return &theme.Focused + } + return &theme.Blurred +} + +func (s *Select) titleView() string { + if s.title == "" { + return "" + } + var ( + styles = s.activeStyles() + sb = strings.Builder{} + ) + if s.filtering { + sb.WriteString(styles.Title.Render(s.filter.View())) + } else if s.filter.Value() != "" && !s.inline { + sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value())) + } else { + sb.WriteString(styles.Title.Render(s.title)) + } + if s.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + } + return sb.String() +} + +func (s *Select) descriptionView() string { + return s.activeStyles().Description.Render(s.description) +} + +func (s *Select) choicesView() string { + var ( + styles = s.activeStyles() + c = styles.SelectSelector.String() + sb strings.Builder + ) + + if s.inline { + sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String()) + if len(s.filteredOptions) > 0 { + sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key)) + } else { + sb.WriteString(styles.TextInput.Placeholder.Render("No matches")) + } + sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String()) + return sb.String() + } + + for i, option := range s.filteredOptions { + if s.newInputActive && i == 0 { + sb.WriteString(c) + sb.WriteString(s.newInput.View()) + sb.WriteString("\n") + continue + } else if s.selected == i { + sb.WriteString(c + styles.SelectedOption.Render(option.Key)) + } else { + sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key)) + } + if i < len(s.options)-1 { + sb.WriteString("\n") + } + } + + for i := len(s.filteredOptions); i < len(s.options)-1; i++ { + sb.WriteString("\n") + } + + return sb.String() +} + +// View renders the select field. +func (s *Select) View() string { + styles := s.activeStyles() + s.viewport.SetContent(s.choicesView()) + + var sb strings.Builder + if s.title != "" { + sb.WriteString(s.titleView()) + if !s.inline { + sb.WriteString("\n") + } + } + if s.description != "" { + sb.WriteString(s.descriptionView()) + if !s.inline { + sb.WriteString("\n") + } + } + sb.WriteString(s.viewport.View()) + return styles.Base.Render(sb.String()) +} + +// clearFilter clears the value of the filter. +func (s *Select) clearFilter() { + s.filter.SetValue("") + s.filteredOptions = s.options + s.setFiltering(false) +} + +// setFiltering sets the filter of the select field. +func (s *Select) setFiltering(filtering bool) { + if s.inline && filtering { + s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1 + } + s.filtering = filtering + s.keymap.SetFilter.SetEnabled(filtering) + s.keymap.Filter.SetEnabled(!filtering) + s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") +} + +// filterFunc returns true if the option matches the filter. +func (s *Select) filterFunc(option string) bool { + // XXX: remove diacritics or allow customization of filter function. + return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) +} + +// Run runs the select field. +func (s *Select) Run() error { + if s.accessible { + return s.runAccessible() + } + return huh.Run(s) +} + +// runAccessible runs an accessible select field. +func (s *Select) runAccessible() error { + var sb strings.Builder + styles := s.activeStyles() + + sb.WriteString(styles.Title.Render(s.title) + "\n") + + for i, option := range s.options { + sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key)) + sb.WriteString("\n") + } + + fmt.Println(sb.String()) + + for { + choice := accessibility.PromptInt("Choose: ", 1, len(s.options)) + option := s.options[choice-1] + if err := s.validate(option.Value); err != nil { + fmt.Println(err.Error()) + continue + } + fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n")) + *s.value = option.Value + break + } + + return nil +} + +// WithTheme sets the theme of the select field. +func (s *Select) WithTheme(theme *huh.Theme) huh.Field { + if s.theme != nil { + return s + } + s.theme = theme + s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor + s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt + s.updateViewportHeight() + return s +} + +// WithKeyMap sets the keymap on a select field. +func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field { + s.keymap = k.Select + s.keymap.Left.SetEnabled(s.inline) + s.keymap.Right.SetEnabled(s.inline) + s.keymap.Up.SetEnabled(!s.inline) + s.keymap.Down.SetEnabled(!s.inline) + return s +} + +// WithAccessible sets the accessible mode of the select field. +func (s *Select) WithAccessible(accessible bool) huh.Field { + s.accessible = accessible + return s +} + +// WithWidth sets the width of the select field. +func (s *Select) WithWidth(width int) huh.Field { + s.width = width + return s +} + +// WithHeight sets the height of the select field. +func (s *Select) WithHeight(height int) huh.Field { + return s.Height(height) +} + +// WithPosition sets the position of the select field. +func (s *Select) WithPosition(p huh.FieldPosition) huh.Field { + if s.filtering { + return s + } + s.keymap.Prev.SetEnabled(!p.IsFirst()) + s.keymap.Next.SetEnabled(!p.IsLast()) + s.keymap.Submit.SetEnabled(p.IsLast()) + return s +} + +// GetKey returns the key of the field. +func (s *Select) GetKey() string { + return s.key +} + +// GetValue returns the value of the field. +func (s *Select) GetValue() any { + return *s.value +} diff --git a/pages/contextPicker.go b/pages/contextPicker.go index 2924ed0..9faf351 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" ) type ContextPickerPage struct { @@ -40,18 +41,32 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { Options(huh.NewOptions(options...)...). Title("Contexts"). Description("Choose a context"). - Value(&selected), + Value(&selected). + WithTheme(common.Styles.Form), ), ). WithShowHelp(false). - WithShowErrors(true). - WithTheme(p.common.Styles.Form) + WithShowErrors(true) + + p.SetSize(common.Width(), common.Height()) return p } 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) + } + + if height >= 30 { + p.form = p.form.WithHeight(30) + } else { + p.form = p.form.WithHeight(height) + } } func (p *ContextPickerPage) Init() tea.Cmd { @@ -96,7 +111,13 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ContextPickerPage) View() string { - return p.common.Styles.Base.Render(p.form.View()) + return lipgloss.Place( + p.common.Width(), + p.common.Height(), + lipgloss.Center, + lipgloss.Center, + p.common.Styles.Base.Render(p.form.View()), + ) } func (p *ContextPickerPage) updateContextCmd() tea.Msg { diff --git a/pages/projectPicker.go b/pages/projectPicker.go index 20bd453..472c012 100644 --- a/pages/projectPicker.go +++ b/pages/projectPicker.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" ) type ProjectPickerPage struct { @@ -37,17 +38,32 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP Options(huh.NewOptions(options...)...). Title("Projects"). Description("Choose a project"). - Value(&selected), + Value(&selected). + WithTheme(common.Styles.Form), ), ). WithShowHelp(false). WithShowErrors(false) + p.SetSize(common.Width(), common.Height()) + return p } 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) + } + + if height >= 30 { + p.form = p.form.WithHeight(30) + } else { + p.form = p.form.WithHeight(height) + } } func (p *ProjectPickerPage) Init() tea.Cmd { @@ -92,7 +108,13 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ProjectPickerPage) View() string { - return p.common.Styles.Base.Render(p.form.View()) + return lipgloss.Place( + p.common.Width(), + p.common.Height(), + lipgloss.Center, + lipgloss.Center, + p.common.Styles.Base.Render(p.form.View()), + ) } func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { diff --git a/pages/reportPicker.go b/pages/reportPicker.go index 573b517..388e09f 100644 --- a/pages/reportPicker.go +++ b/pages/reportPicker.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" ) type ReportPickerPage struct { @@ -38,17 +39,32 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report Options(huh.NewOptions(options...)...). Title("Reports"). Description("Choose a report"). - Value(&selected), + Value(&selected). + WithTheme(common.Styles.Form), ), ). WithShowHelp(false). WithShowErrors(false) + p.SetSize(common.Width(), common.Height()) + return p } func (p *ReportPickerPage) 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) + } + + if height >= 30 { + p.form = p.form.WithHeight(30) + } else { + p.form = p.form.WithHeight(height) + } } func (p *ReportPickerPage) Init() tea.Cmd { @@ -93,7 +109,13 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ReportPickerPage) View() string { - return p.common.Styles.Base.Render(p.form.View()) + return lipgloss.Place( + p.common.Width(), + p.common.Height(), + lipgloss.Center, + lipgloss.Center, + p.common.Styles.Base.Render(p.form.View()), + ) } func (p *ReportPickerPage) updateReportCmd() tea.Msg { diff --git a/pages/taskEditor.go b/pages/taskEditor.go index db40bc3..4854117 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -382,13 +382,13 @@ type taskEdit struct { fields []huh.Field cursor int - newProjectName *string - newAnnotation *string - udaValues map[string]*string + // newProjectName *string + newAnnotation *string + udaValues map[string]*string } func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { - newProject := "" + // newProject := "" projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...) if task.Project == "" { task.Project = "(none)" @@ -410,19 +410,19 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { Prompt(": "). WithTheme(com.Styles.Form), - huh.NewSelect[string](). - Options(huh.NewOptions(projectOptions...)...). + input.NewSelect(com). + Options(true, input.NewOptions(projectOptions...)...). Title("Project"). Value(&task.Project). WithKeyMap(defaultKeymap). WithTheme(com.Styles.Form), - huh.NewInput(). - Title("New Project"). - Value(&newProject). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form), + // huh.NewInput(). + // Title("New Project"). + // Value(&newProject). + // Inline(true). + // Prompt(": "). + // WithTheme(com.Styles.Form), } udaValues := make(map[string]*string) @@ -513,8 +513,8 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { udaValues: udaValues, - newProjectName: &newProject, - newAnnotation: &newAnnotation, + // newProjectName: &newProject, + newAnnotation: &newAnnotation, } t.fields[0].Focus() @@ -997,9 +997,9 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg { } } - if *(p.areas[0].(*taskEdit).newProjectName) != "" { - p.task.Project = *p.areas[0].(*taskEdit).newProjectName - } + // if *(p.areas[0].(*taskEdit).newProjectName) != "" { + // p.task.Project = *p.areas[0].(*taskEdit).newProjectName + // } if *(p.areas[1].(*tagEdit).newTagsValue) != "" { newTags := strings.Split(*p.areas[1].(*tagEdit).newTagsValue, " ") diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index 9a089b38d8d1da625e48c291ac5376b56ff81a5b..0767e070bc5785f9c2ad50c221d38048942b40d1 100644 GIT binary patch delta 6009 zcmcIod2Afj9iExDv-i&0j_vgkU+Xnau)Ut+&1GmV!Vw?@6AUF3l1;oJF2s%#r%7*G5Po5P<{&)p3LiQVLWm`2&yMS%L=9DB?z{jdJR z+MRvxd*AQ+e!n+UPgG4!Rqt=|w=on&t)6XP#)fl{ z%V;h`cg-KHYUXP2FP9lN;s2`)75`shG|a!*Dpk{4kMRHM4>aLN$$gj2U)U%F=?~1_ zZA;m|Ed@gintKPN%)Q&bW6xV(#}9zi{Na%kw4bda56rpg)coUjZu5aKw{uA3v{vHaZ7v&&>nB20o%{^&*r*(c;W9KT?Y7s zrEFhjSkT!Z(=rVnTvv?#Ila&hz^Ak?I8S|rqAchP=B}RE=o^|IM1vPVir#}Biu;yz zWk)A={yaI56jVVIR9;v3WN$J%I&44EMF~O~qEmiQTep2|cx3BH*7MWKTBdg4TNW%Z z!Pqs_3E$AZTg}Y9DfB@QJh^bDhGFSoZqtK%@xhbCEel7&;J1t~-bS5SSQi7QXzUm2 z9RBrg%gYPb#KB8vm$gvGsrXgg6S1#i>!Qy^4@7T?%!JQ{mC*j+XTf)aJpsf2iT}_3 zi`bm+L*Hp%2bgBgFmE#5^e^eVspGS`2SBdZK$#h)0iCF!MYQ5urm5L71`61|pJnda zoh`BuqI2z_p+|%_3eo_t4v1nJ^1Pr38p{HeWeh%1l&sO@P+AUZWb(tuC`7?~v%1dB+q^ z7CT2si>d}~B+jVvux>WuKja)fE#hpFT<37**6~HIB&#JTSTVrSj8!BoX_W}0!ObP% zBv7Kr>$abx9>yI;q@0QE=K>u;OKXY*_PYJVZ@0PJ5m|n67jj%vR zZ?`b@0SB<$cwG^6UPK)s(45Hbn%F&@!O+=}(Ot>O-0GPu7N^TekD&KF76c9GjRt0s zxppuTu=G+AEparzU%&X#qae{%Ww}Hct92EBtr6)lQG-}+Nh&c z{MPu@+yN~7AIFx*($R+_??$9ZGQ2bNRwx~632qO(9#|4c`0rp}VFSMZ_^tqtFn?#> zX1eG}>QB_s+1zL&XQ-vcl7oigqo%i`)6asd(5Vryge%Py^YjCYol+OX0Z~s2ydXf; zD)p2ho4G>?)c*ny%|E>B4>%cSp08Z~p(Qg|u^>8h081?Y0ua!qUpNg& zg`}E1601UV`i{I}*%WwPQ&hz+e#v7`$`DI9l@w3)IsjX386ij_?lC&ASV-6rLWv{l zYRW6d6b|lC@8UsvD%4>vVCVpJXms3H2kd~Wd$nu?p5om<@=+8xG4=IN)Ld>{K~!kRZ+F6w}!r%6|a zc7JO&x={7wPLh`n9M(cnliYzL<#9AR`5a*D1*rmKx!BQo9J8Qsd$J0WGdewtQ%2GX z{wJ;?~FpXob+BTEXa2#R>S{uID*My$E0K(OtnuIbMuchk? z5yLzPilWGN?z9>0=J-bmf&$T(i|KYh5j1C8Up~;zHd&FKF|W--zr$!P8b1!|Ou|w| z2o)mBuj;zyltEJ&t;SMX3EVg(o>v6Ryt|xwaE5L{vS(+z8{9y}Z{wcl*x0{g ztD^^_k3`;&EDs+D&xGCy!BA&#FYX4%0`&pbKg7Ps*7=5g>%mjN$E>H1(1)n=)D5_$ z=1L9}9=)|}A70YMoMWildayqm2<75t%zP$|n+P1aE6J*U3%P?D+(jz4bn z_@l*GkH(MTLU(3#KeKxjo&69fR*hix zRs92pYIL`GZl2V(t33WET%6(CAdWduwSCkfDK^HK5 z@y%D12V^m=^W;^4-5f42!&+U6WoZ=|B&}k01lPt9HCf=TUQYz~(EG)HCh~%J$z{1! zk&&oDUbHfrGgg%`T3N)%xnq}H-Y^>|r)7N2R^6tx!cCCkOR~F8#u{X0U$3#;6B37Z zc}ZYf3MwFBHL;JBG*=aumC^oD5p68`#X*6korj>oG#1Rl?fudU#31e%75r&~<@=EX zChL+tI&sD&9%?CLAU{i?-wo2;q?I}YfS?cXFfHQilI(U4-P6~t&Vk4Jaw>wyE*A{H zmax;L&CsllN=9vTP@t2fci}sr<&G6@M|HPB-5!rgtEdWYpyFS|mvg`29*q4Z)*ow) zjz&&IjzwbOt>K~2kq{HyFqad9zFf-KYFS50{;>m~Ff2HQDPd>xX;o8s`{s>vH(SBk zx+;`gh_AKQn+Fe+tZVMXFpTI=!J52n?gpop-Lvs@>dXpTJhACCHW!J?*~4H};VeN< ztBQtG&Pm>khc?j@OuM}1bk)qf^Ds#nKXGGOP}n+vIicB; z&a`9U;89z^@4oVsZuL;IWMARSWnWj(Ygtg_8w*L1R7W2-co=mWw^WV~9x))+d-9Ie iy?Ok&5(!x=kB_FHj$Z-$)e!DH4ieUTNAPbhCGlTfMU&D1 delta 680 zcmXw#Ur1A77{<>z-+TV8(-x+oHd!Fz8o|os%X0_~!uh_^n zty2`$zYyqUn$}BWtE$lHtq_Omvt-{C9`B&r?d4*J*RJdjYc#(W`hJ)nB+vSM!rmS= z1-5b!r5IeT2ZMvxH7>nHhBY^}!w%Qv^b#`c)$|60N_oS+e{l+sqI|abt36)p z?pzQgM5VAFkioWyE>5qxD7cuEfFSQ8y~{T; zxd}8ucV#|X&eBjsD)kEUKIXZQNAn3t-8yEO4-My=kI}vcNiUU(Q}P+l1?e^)WOrD> z+Y0q4pu`y@*s7oULd6$yLCE5C3kL4wK#8Z%}Qba5YTcun@aJGJ<-MmPSMymsqI$&#o9#zOi3nxi>J5FvRWA6?9 z(v^wy`Sj$(|8~((ED|CYILo45>&I!$iYJxEZu6h5XC`Md*C)ow^kn+-j6VApu0pzC