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"`
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_"`

View file

@ -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

View file

@ -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
}

View file

@ -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"`
}