feat(choose): Ability to choose multiple choices --limit

This commit introduces the ability to choose multiple options from the list of choices in `gum choose` by adding a `--limit` flag.
This commit is contained in:
Maas Lalani 2022-07-13 11:45:52 -04:00
parent 5de4df66d2
commit 01404ef586
No known key found for this signature in database
GPG key ID: 5A6ED5CBF1A0A000
3 changed files with 121 additions and 82 deletions

View file

@ -12,57 +12,35 @@
package choose package choose
import ( import (
"fmt" "strings"
"io"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
) )
type model struct { type model struct {
choice string height int
height int page int
indicator string cursor string
indicatorStyle lipgloss.Style selectedPrefix string
itemStyle lipgloss.Style unselectedPrefix string
items []item cursorPrefix string
list list.Model items []item
options []string quitting bool
quitting bool index int
selectedItemStyle lipgloss.Style limit int
} numSelected int
type item string // styles
cursorStyle lipgloss.Style
func (i item) FilterValue() string { return "" }
type itemDelegate struct {
indicator string
indicatorStyle lipgloss.Style
itemStyle lipgloss.Style itemStyle lipgloss.Style
selectedItemStyle lipgloss.Style selectedItemStyle lipgloss.Style
} }
func (d itemDelegate) Height() int { return 1 } type item struct {
func (d itemDelegate) Spacing() int { return 0 } text string
func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } selected bool
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
i, ok := listItem.(item)
if !ok {
return
}
str := fmt.Sprintf("%s", i)
fn := d.itemStyle.Render
if index == m.Index() {
fn = func(s string) string {
return d.indicatorStyle.Render(d.indicator) + d.selectedItemStyle.Render(s)
}
}
fmt.Fprintf(w, fn(str))
} }
func (m model) Init() tea.Cmd { return nil } func (m model) Init() tea.Cmd { return nil }
@ -70,43 +48,92 @@ func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.list.SetWidth(msg.Width)
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
switch keypress := msg.String(); keypress { switch keypress := msg.String(); keypress {
case "down", "j", "ctrl+n":
m.index = (m.index + 1) % len(m.items)
m.page = m.index / m.height
case "up", "k", "ctrl+p":
m.index = (m.index - 1 + len(m.items)) % len(m.items)
m.page = m.index / m.height
case "right", "l", "ctrl+f":
if m.index+m.height < len(m.items) {
m.index += m.height
} else {
if m.page < len(m.items)/m.height {
m.index = len(m.items) - 1
}
}
m.page = m.index / m.height
case "left", "h", "ctrl+b":
if m.index-m.height >= 0 {
m.index -= m.height
}
m.page = m.index / m.height
case "ctrl+c": case "ctrl+c":
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
case " ", "x":
if m.limit == 1 {
break // no op
}
if m.items[m.index].selected {
m.items[m.index].selected = false
m.numSelected--
} else if m.numSelected < m.limit {
m.items[m.index].selected = true
m.numSelected++
}
case "enter": case "enter":
m.quitting = true m.quitting = true
i, ok := m.list.SelectedItem().(item) // Select the item on which they've hit enter if it falls within
if ok { // the limit.
m.choice = string(i) if m.numSelected < m.limit {
m.items[m.index].selected = true
} }
return m, tea.Quit return m, tea.Quit
} }
} }
var cmd tea.Cmd return m, nil
m.list, cmd = m.list.Update(msg)
return m, cmd
} }
func (m model) View() string { func (m model) View() string {
if m.quitting { if m.quitting {
return "" return ""
} }
return m.list.View()
var s strings.Builder
for i, item := range m.items[clamp(m.page*m.height, 0, len(m.items)):clamp((m.page+1)*m.height, 0, len(m.items))] {
if i == m.index%m.height {
s.WriteString(m.cursorStyle.Render(m.cursor))
} else {
s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.cursor)))
}
if item.selected {
s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text))
} else if i == m.index%m.height {
s.WriteString(m.cursorStyle.Render(m.cursorPrefix + item.text))
} else {
s.WriteString(m.itemStyle.Render(m.unselectedPrefix + item.text))
}
s.WriteRune('\n')
}
return s.String()
} }
func clamp(min, max, val int) int { func clamp(x, min, max int) int {
if val < min { if x < min {
return min return min
} }
if val > max { if x > max {
return max return max
} }
return val return x
} }

View file

@ -5,10 +5,8 @@ import (
"os" "os"
"strings" "strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stdin" "github.com/charmbracelet/gum/internal/stdin"
"github.com/mattn/go-runewidth"
) )
// Run provides a shell script interface for choosing between different through // Run provides a shell script interface for choosing between different through
@ -16,34 +14,45 @@ import (
func (o Options) Run() error { func (o Options) Run() error {
if len(o.Options) == 0 { if len(o.Options) == 0 {
input, _ := stdin.Read() input, _ := stdin.Read()
o.Options = strings.Split(input, "\n") o.Options = strings.Split(strings.TrimSpace(input), "\n")
} }
items := []list.Item{} var items []item
for _, option := range o.Options { for _, option := range o.Options {
if option == "" { items = append(items, item{text: option, selected: false})
continue }
// We don't need to display prefixes if we are only picking one option.
// Simply displaying the cursor is enough.
if o.Limit == 1 {
o.SelectedPrefix = ""
o.UnselectedPrefix = ""
o.CursorPrefix = ""
}
m, err := tea.NewProgram(model{
height: o.Height,
cursor: o.Cursor,
selectedPrefix: o.SelectedPrefix,
unselectedPrefix: o.UnselectedPrefix,
cursorPrefix: o.CursorPrefix,
items: items,
limit: o.Limit,
cursorStyle: o.CursorStyle.ToLipgloss(),
itemStyle: o.ItemStyle.ToLipgloss(),
selectedItemStyle: o.SelectedItemStyle.ToLipgloss(),
}, tea.WithOutput(os.Stderr)).StartReturningModel()
var s strings.Builder
for _, item := range m.(model).items {
if item.selected {
s.WriteString(item.text)
s.WriteRune('\n')
} }
items = append(items, item(option))
} }
const defaultWidth = 20 fmt.Println(strings.TrimSuffix(s.String(), "\n"))
id := itemDelegate{
indicator: o.Indicator,
indicatorStyle: o.IndicatorStyle.ToLipgloss(),
selectedItemStyle: o.SelectedStyle.ToLipgloss(),
itemStyle: o.ItemStyle.ToLipgloss().MarginLeft(runewidth.StringWidth(o.Indicator)),
}
l := list.New(items, id, defaultWidth, o.Height)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.SetShowPagination(!o.HidePagination)
m, err := tea.NewProgram(model{list: l}, tea.WithOutput(os.Stderr)).StartReturningModel()
fmt.Println(m.(model).choice)
return err return err
} }

View file

@ -6,10 +6,13 @@ import "github.com/charmbracelet/gum/style"
type Options struct { type Options struct {
Options []string `arg:"" optional:"" help:"Options to choose from."` Options []string `arg:"" optional:"" help:"Options to choose from."`
Height int `help:"Height of the list" default:"10"` Limit int `help:"Maximum number of options to pick" default:"1"`
HidePagination bool `help:"Hide pagination" default:"false"` Height int `help:"Height of the list" default:"10"`
Indicator string `help:"Prefix to show on selected item" default:"> "` Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> "`
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" set:"name=indicator"` CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"• "`
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" set:"defaultForeground=255" set:"name=item"` SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"✕ "`
SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" set:"name=selected item"` UnselectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:" "`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" set:"name=indicator"`
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" set:"defaultForeground=255" set:"name=item"`
SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" set:"name=selected item"`
} }