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,6 +58,10 @@ 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,
@ -65,9 +69,15 @@ func (o Options) Run() error {
textinput: i, textinput: i,
viewport: &v, viewport: &v,
indicatorStyle: o.IndicatorStyle.ToLipgloss(), indicatorStyle: o.IndicatorStyle.ToLipgloss(),
selectedPrefixStyle: o.SelectedPrefixStyle.ToLipgloss(),
selectedPrefix: o.SelectedPrefix,
unselectedPrefixStyle: o.UnselectedPrefixStyle.ToLipgloss(),
unselectedPrefix: o.UnselectedPrefix,
matchStyle: o.MatchStyle.ToLipgloss(), matchStyle: o.MatchStyle.ToLipgloss(),
textStyle: o.TextStyle.ToLipgloss(), textStyle: o.TextStyle.ToLipgloss(),
height: o.Height, 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

@ -26,14 +26,21 @@ type model struct {
viewport *viewport.Model viewport *viewport.Model
choices []string choices []string
matches []fuzzy.Match matches []fuzzy.Match
selected int cursor int
selected map[string]struct{}
limit int
numSelected int
indicator string indicator string
selectedPrefix string
unselectedPrefix string
height int height int
aborted bool aborted bool
quitting bool quitting bool
matchStyle lipgloss.Style matchStyle lipgloss.Style
textStyle lipgloss.Style textStyle lipgloss.Style
indicatorStyle 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

@ -6,6 +6,12 @@ import "github.com/charmbracelet/gum/style"
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_"`
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_"` TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"`
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"` MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"`
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"` Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"`