mirror of
https://github.com/charmbracelet/gum
synced 2024-05-23 16:42:22 +02:00
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:
parent
81602545a6
commit
c9fe558a44
|
@ -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_"`
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue