feat(filter): Multi-select (#130)

* 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 <maas@lalani.dev>
This commit is contained in:
fedeztk 2022-09-03 03:21:31 +02:00 committed by GitHub
parent 81602545a6
commit c9fe558a44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 102 additions and 43 deletions

View file

@ -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"` 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"` 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"` 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_"` CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"` ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"` SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`

View file

@ -58,16 +58,26 @@ func (o Options) Run() error {
matches = matchAll(choices) matches = matchAll(choices)
} }
if o.NoLimit {
o.Limit = len(choices)
}
p := tea.NewProgram(model{ p := tea.NewProgram(model{
choices: choices, choices: choices,
indicator: o.Indicator, indicator: o.Indicator,
matches: matches, matches: matches,
textinput: i, textinput: i,
viewport: &v, viewport: &v,
indicatorStyle: o.IndicatorStyle.ToLipgloss(), indicatorStyle: o.IndicatorStyle.ToLipgloss(),
matchStyle: o.MatchStyle.ToLipgloss(), selectedPrefixStyle: o.SelectedPrefixStyle.ToLipgloss(),
textStyle: o.TextStyle.ToLipgloss(), selectedPrefix: o.SelectedPrefix,
height: o.Height, 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...) }, options...)
tm, err := p.StartReturningModel() tm, err := p.StartReturningModel()
@ -79,8 +89,16 @@ func (o Options) Run() error {
if m.aborted { if m.aborted {
return exit.ErrAborted 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 return nil

View file

@ -22,18 +22,25 @@ import (
) )
type model struct { type model struct {
textinput textinput.Model textinput textinput.Model
viewport *viewport.Model viewport *viewport.Model
choices []string choices []string
matches []fuzzy.Match matches []fuzzy.Match
selected int cursor int
indicator string selected map[string]struct{}
height int limit int
aborted bool numSelected int
quitting bool indicator string
matchStyle lipgloss.Style selectedPrefix string
textStyle lipgloss.Style unselectedPrefix string
indicatorStyle lipgloss.Style 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 } func (m model) Init() tea.Cmd { return nil }
@ -49,10 +56,19 @@ func (m model) View() string {
for i, match := range m.matches { for i, match := range m.matches {
// If this is the current selected index, we add a small indicator to // If this is the current selected index, we add a small indicator to
// represent it. Otherwise, simply pad the string. // represent it. Otherwise, simply pad the string.
if i == m.selected { if i == m.cursor {
s.WriteString(m.indicatorStyle.Render(m.indicator) + " ") s.WriteString(m.indicatorStyle.Render(m.indicator))
} else { } 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 // 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 m.quitting = true
return m, tea.Quit return m, tea.Quit
case "ctrl+n", "ctrl+j", "down": case "ctrl+n", "ctrl+j", "down":
m.selected = clamp(0, len(m.matches)-1, m.selected+1) m.cursor = clamp(0, len(m.matches)-1, m.cursor+1)
if m.selected >= m.viewport.YOffset+m.viewport.Height { if m.cursor >= m.viewport.YOffset+m.viewport.Height {
m.viewport.LineDown(1) m.viewport.LineDown(1)
} }
case "ctrl+p", "ctrl+k", "up": case "ctrl+p", "ctrl+k", "up":
m.selected = clamp(0, len(m.matches)-1, m.selected-1) m.cursor = clamp(0, len(m.matches)-1, m.cursor-1)
if m.selected < m.viewport.YOffset { if m.cursor < m.viewport.YOffset {
m.viewport.SetYOffset(m.selected) 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: default:
m.textinput, cmd = m.textinput.Update(msg) 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 // 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. // 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 return m, cmd
} }

View file

@ -4,14 +4,20 @@ import "github.com/charmbracelet/gum/style"
// Options is the customization options for the filter command. // Options is the customization options for the filter command.
type Options struct { type Options struct {
Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"` Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"`
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"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_"` Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"` NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"` SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"`
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"` SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"`
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"` UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"`
Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"` UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"`
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"` TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"`
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"` 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"`
} }