From c9fe558a4434d2d4e4e19fc026d90419b69c9e10 Mon Sep 17 00:00:00 2001 From: fedeztk <58485208+fedeztk@users.noreply.github.com> Date: Sat, 3 Sep 2022 03:21:31 +0200 Subject: [PATCH] feat(filter): Multi-select (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(filter): added multiple selections * docs(filter): corrected comments about multiSelection print * feat(filter): fix proposed changes on naming variables and map type * feat(filter): actually fix map types * feat(filter): use `○` / `◉` as unselected / selected multi-select options Co-authored-by: Maas Lalani --- choose/options.go | 2 +- filter/command.go | 40 +++++++++++++++++------- filter/filter.go | 77 ++++++++++++++++++++++++++++++++++------------- filter/options.go | 26 ++++++++++------ 4 files changed, 102 insertions(+), 43 deletions(-) diff --git a/choose/options.go b/choose/options.go index 04a5cdd..b936047 100644 --- a/choose/options.go +++ b/choose/options.go @@ -12,7 +12,7 @@ type Options struct { Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> " env:"GUM_CHOOSE_CURSOR"` CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"[•] " env:"GUM_CHOOSE_CURSOR_PREFIX"` SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"[✕] " env:"GUM_CHOOSE_SELECTED_PREFIX"` - UnselectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"[ ] " env:"GUM_CHOOSE_UNSELECTED_PREFIX"` + UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"[ ] " env:"GUM_CHOOSE_UNSELECTED_PREFIX"` CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"` ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"` SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"` diff --git a/filter/command.go b/filter/command.go index 8a00b7d..eeada2f 100644 --- a/filter/command.go +++ b/filter/command.go @@ -58,16 +58,26 @@ func (o Options) Run() error { matches = matchAll(choices) } + if o.NoLimit { + o.Limit = len(choices) + } + p := tea.NewProgram(model{ - choices: choices, - indicator: o.Indicator, - matches: matches, - textinput: i, - viewport: &v, - indicatorStyle: o.IndicatorStyle.ToLipgloss(), - matchStyle: o.MatchStyle.ToLipgloss(), - textStyle: o.TextStyle.ToLipgloss(), - height: o.Height, + choices: choices, + indicator: o.Indicator, + matches: matches, + textinput: i, + viewport: &v, + indicatorStyle: o.IndicatorStyle.ToLipgloss(), + selectedPrefixStyle: o.SelectedPrefixStyle.ToLipgloss(), + selectedPrefix: o.SelectedPrefix, + unselectedPrefixStyle: o.UnselectedPrefixStyle.ToLipgloss(), + unselectedPrefix: o.UnselectedPrefix, + matchStyle: o.MatchStyle.ToLipgloss(), + textStyle: o.TextStyle.ToLipgloss(), + height: o.Height, + selected: make(map[string]struct{}), + limit: o.Limit, }, options...) tm, err := p.StartReturningModel() @@ -79,8 +89,16 @@ func (o Options) Run() error { if m.aborted { return exit.ErrAborted } - if len(m.matches) > m.selected && m.selected >= 0 { - fmt.Println(m.matches[m.selected].Str) + + // allSelections contains values only if limit is greater + // than 1 or if flag --no-limit is passed, hence there is + // no need to further checks + if len(m.selected) > 0 { + for k := range m.selected { + fmt.Println(k) + } + } else if len(m.matches) > m.cursor && m.cursor >= 0 { + fmt.Println(m.matches[m.cursor].Str) } return nil diff --git a/filter/filter.go b/filter/filter.go index 79e4dec..acb1a95 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -22,18 +22,25 @@ import ( ) type model struct { - textinput textinput.Model - viewport *viewport.Model - choices []string - matches []fuzzy.Match - selected int - indicator string - height int - aborted bool - quitting bool - matchStyle lipgloss.Style - textStyle lipgloss.Style - indicatorStyle lipgloss.Style + textinput textinput.Model + viewport *viewport.Model + choices []string + matches []fuzzy.Match + cursor int + selected map[string]struct{} + limit int + numSelected int + indicator string + selectedPrefix string + unselectedPrefix string + height int + aborted bool + quitting bool + matchStyle lipgloss.Style + textStyle lipgloss.Style + indicatorStyle lipgloss.Style + selectedPrefixStyle lipgloss.Style + unselectedPrefixStyle lipgloss.Style } func (m model) Init() tea.Cmd { return nil } @@ -49,10 +56,19 @@ func (m model) View() string { for i, match := range m.matches { // If this is the current selected index, we add a small indicator to // represent it. Otherwise, simply pad the string. - if i == m.selected { - s.WriteString(m.indicatorStyle.Render(m.indicator) + " ") + if i == m.cursor { + s.WriteString(m.indicatorStyle.Render(m.indicator)) } else { - s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator)) + " ") + s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator))) + } + + // If there are multiple selections mark them, otherwise leave an empty space + if _, ok := m.selected[match.Str]; ok { + s.WriteString(m.selectedPrefixStyle.Render(m.selectedPrefix)) + } else if m.limit > 1 { + s.WriteString(m.unselectedPrefixStyle.Render(m.unselectedPrefix)) + } else { + s.WriteString(" ") } // For this match, there are a certain number of characters that have @@ -102,14 +118,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit case "ctrl+n", "ctrl+j", "down": - m.selected = clamp(0, len(m.matches)-1, m.selected+1) - if m.selected >= m.viewport.YOffset+m.viewport.Height { + m.cursor = clamp(0, len(m.matches)-1, m.cursor+1) + if m.cursor >= m.viewport.YOffset+m.viewport.Height { m.viewport.LineDown(1) } case "ctrl+p", "ctrl+k", "up": - m.selected = clamp(0, len(m.matches)-1, m.selected-1) - if m.selected < m.viewport.YOffset { - m.viewport.SetYOffset(m.selected) + m.cursor = clamp(0, len(m.matches)-1, m.cursor-1) + if m.cursor < m.viewport.YOffset { + m.viewport.SetYOffset(m.cursor) + } + case "tab": + if m.limit == 1 { + break // no op + } + + // Tab is used to toggle selection of current item in the list + if _, ok := m.selected[m.matches[m.cursor].Str]; ok { + delete(m.selected, m.matches[m.cursor].Str) + m.numSelected-- + } else if m.numSelected < m.limit { + m.selected[m.matches[m.cursor].Str] = struct{}{} + m.numSelected++ + } + + // Go down by one line + m.cursor = clamp(0, len(m.matches)-1, m.cursor+1) + if m.cursor >= m.viewport.YOffset+m.viewport.Height { + m.viewport.LineDown(1) } default: m.textinput, cmd = m.textinput.Update(msg) @@ -130,7 +165,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // It's possible that filtering items have caused fewer matches. So, ensure // that the selected index is within the bounds of the number of matches. - m.selected = clamp(0, len(m.matches)-1, m.selected) + m.cursor = clamp(0, len(m.matches)-1, m.cursor) return m, cmd } diff --git a/filter/options.go b/filter/options.go index 5173c84..6cc552f 100644 --- a/filter/options.go +++ b/filter/options.go @@ -4,14 +4,20 @@ import "github.com/charmbracelet/gum/style" // Options is the customization options for the filter command. type Options struct { - Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"` - IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"` - TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"` - MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"` - Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"` - Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"` - PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"` - Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"` - Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"` - Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"` + Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"` + IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"` + Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"` + NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"` + SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"` + SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"` + UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"` + UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"` + TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"` + MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"` + Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"` + Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"` + PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"` + Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"` + Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"` + Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"` }