From 01404ef5862c718774e45ac8f1329671be2fa98e Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 13 Jul 2022 11:45:52 -0400 Subject: [PATCH] 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. --- choose/choose.go | 131 ++++++++++++++++++++++++++++------------------ choose/command.go | 57 +++++++++++--------- choose/options.go | 15 +++--- 3 files changed, 121 insertions(+), 82 deletions(-) diff --git a/choose/choose.go b/choose/choose.go index 7b050b2..6d947cc 100644 --- a/choose/choose.go +++ b/choose/choose.go @@ -12,57 +12,35 @@ package choose import ( - "fmt" - "io" + "strings" - "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) type model struct { - choice string - height int - indicator string - indicatorStyle lipgloss.Style - itemStyle lipgloss.Style - items []item - list list.Model - options []string - quitting bool - selectedItemStyle lipgloss.Style -} + height int + page int + cursor string + selectedPrefix string + unselectedPrefix string + cursorPrefix string + items []item + quitting bool + index int + limit int + numSelected int -type item string - -func (i item) FilterValue() string { return "" } - -type itemDelegate struct { - indicator string - indicatorStyle lipgloss.Style + // styles + cursorStyle lipgloss.Style itemStyle lipgloss.Style selectedItemStyle lipgloss.Style } -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -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)) +type item struct { + text string + selected bool } 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) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.list.SetWidth(msg.Width) return m, nil case tea.KeyMsg: 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": m.quitting = true 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": m.quitting = true - i, ok := m.list.SelectedItem().(item) - if ok { - m.choice = string(i) + // Select the item on which they've hit enter if it falls within + // the limit. + if m.numSelected < m.limit { + m.items[m.index].selected = true } return m, tea.Quit } } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd + return m, nil } func (m model) View() string { if m.quitting { 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 { - if val < min { +func clamp(x, min, max int) int { + if x < min { return min } - if val > max { + if x > max { return max } - return val + return x } diff --git a/choose/command.go b/choose/command.go index eea7c2e..c22e441 100644 --- a/choose/command.go +++ b/choose/command.go @@ -5,10 +5,8 @@ import ( "os" "strings" - "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/stdin" - "github.com/mattn/go-runewidth" ) // Run provides a shell script interface for choosing between different through @@ -16,34 +14,45 @@ import ( func (o Options) Run() error { if len(o.Options) == 0 { 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 { - if option == "" { - continue + items = append(items, item{text: option, selected: false}) + } + + // 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 } diff --git a/choose/options.go b/choose/options.go index cc07ee6..d2d2b6f 100644 --- a/choose/options.go +++ b/choose/options.go @@ -6,10 +6,13 @@ import "github.com/charmbracelet/gum/style" type Options struct { Options []string `arg:"" optional:"" help:"Options to choose from."` - Height int `help:"Height of the list" default:"10"` - HidePagination bool `help:"Hide pagination" default:"false"` - Indicator string `help:"Prefix to show on selected item" default:"> "` - IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" set:"name=indicator"` - ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" set:"defaultForeground=255" set:"name=item"` - SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" set:"name=selected item"` + Limit int `help:"Maximum number of options to pick" default:"1"` + Height int `help:"Height of the list" default:"10"` + Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> "` + CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"• "` + SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"✕ "` + 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"` }