diff --git a/choose/choose.go b/choose/choose.go new file mode 100644 index 000000000..42eae176e --- /dev/null +++ b/choose/choose.go @@ -0,0 +1,316 @@ +// Package choose provides an interface to choose one option from a given list +// of options. The options can be provided as (new-line separated) stdin or a +// list of arguments. +// +// It is different from the filter command as it does not provide a fuzzy +// finding input, so it is best used for smaller lists of options. +// +// Let's pick from a list of gum flavors: +// +// $ gum choose "Strawberry" "Banana" "Cherry" +package choose + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/timeout" + "github.com/charmbracelet/lipgloss" +) + +func defaultKeymap() keymap { + return keymap{ + Down: key.NewBinding( + key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), + key.WithHelp("↓", "down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), + key.WithHelp("↑", "up"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l", "ctrl+f"), + key.WithHelp("→", "right"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h", "ctrl+b"), + key.WithHelp("←", "left"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + ToggleAll: key.NewBinding( + key.WithKeys("a", "A", "ctrl+a"), + key.WithHelp("ctrl+a", "select all"), + key.WithDisabled(), + ), + Toggle: key.NewBinding( + key.WithKeys(" ", "tab", "x", "ctrl+@"), + key.WithHelp("x", "toggle"), + key.WithDisabled(), + ), + Abort: key.NewBinding( + key.WithKeys("ctrl+c", "esc"), + key.WithHelp("ctrl+c", "abort"), + ), + Submit: key.NewBinding( + key.WithKeys("enter", "ctrl+q"), + key.WithHelp("enter", "submit"), + ), + } +} + +type keymap struct { + Down, + Up, + Right, + Left, + Home, + End, + ToggleAll, + Toggle, + Abort, + Submit key.Binding +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Toggle, + key.NewBinding( + key.WithKeys("up", "down", "right", "left"), + key.WithHelp("↑↓←→", "navigate"), + ), + k.Submit, + k.ToggleAll, + } +} + +type model struct { + height int + cursor string + selectedPrefix string + unselectedPrefix string + cursorPrefix string + header string + items []item + quitting bool + index int + limit int + numSelected int + currentOrder int + paginator paginator.Model + aborted bool + timedOut bool + showHelp bool + help help.Model + keymap keymap + + // styles + cursorStyle lipgloss.Style + headerStyle lipgloss.Style + itemStyle lipgloss.Style + selectedItemStyle lipgloss.Style + hasTimeout bool + timeout time.Duration +} + +type item struct { + text string + selected bool + order int +} + +func (m model) Init() tea.Cmd { + return timeout.Init(m.timeout, nil) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m, nil + case timeout.TickTimeoutMsg: + if msg.TimeoutValue <= 0 { + m.quitting = true + m.timedOut = true + // If the user hasn't selected any items in a multi-select. + // Then we select the item that they have pressed enter on. If they + // have selected items, then we simply return them. + if m.numSelected < 1 { + m.items[m.index].selected = true + } + return m, tea.Quit + } + m.timeout = msg.TimeoutValue + return m, timeout.Tick(msg.TimeoutValue, msg.Data) + case tea.KeyMsg: + start, end := m.paginator.GetSliceBounds(len(m.items)) + km := m.keymap + switch { + case key.Matches(msg, km.Down): + m.index++ + if m.index >= len(m.items) { + m.index = 0 + m.paginator.Page = 0 + } + if m.index >= end { + m.paginator.NextPage() + } + case key.Matches(msg, km.Up): + m.index-- + if m.index < 0 { + m.index = len(m.items) - 1 + m.paginator.Page = m.paginator.TotalPages - 1 + } + if m.index < start { + m.paginator.PrevPage() + } + case key.Matches(msg, km.Right): + m.index = clamp(m.index+m.height, 0, len(m.items)-1) + m.paginator.NextPage() + case key.Matches(msg, km.Left): + m.index = clamp(m.index-m.height, 0, len(m.items)-1) + m.paginator.PrevPage() + case key.Matches(msg, km.End): + m.index = len(m.items) - 1 + m.paginator.Page = m.paginator.TotalPages - 1 + case key.Matches(msg, km.Home): + m.index = 0 + m.paginator.Page = 0 + case key.Matches(msg, km.ToggleAll): + if m.limit <= 1 { + break + } + if m.numSelected < len(m.items) && m.numSelected < m.limit { + m = m.selectAll() + } else { + m = m.deselectAll() + } + case key.Matches(msg, km.Abort): + m.aborted = true + m.quitting = true + return m, tea.Quit + case key.Matches(msg, km.Toggle): + 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.items[m.index].order = m.currentOrder + m.numSelected++ + m.currentOrder++ + } + case key.Matches(msg, km.Submit): + m.quitting = true + if m.limit <= 1 && m.numSelected < 1 { + m.items[m.index].selected = true + } + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.paginator, cmd = m.paginator.Update(msg) + return m, cmd +} + +func (m model) selectAll() model { + for i := range m.items { + if m.numSelected >= m.limit { + break // do not exceed given limit + } + if m.items[i].selected { + continue + } + m.items[i].selected = true + m.items[i].order = m.currentOrder + m.numSelected++ + m.currentOrder++ + } + return m +} + +func (m model) deselectAll() model { + for i := range m.items { + m.items[i].selected = false + m.items[i].order = 0 + } + m.numSelected = 0 + m.currentOrder = 0 + return m +} + +func (m model) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + var timeoutStr string + + start, end := m.paginator.GetSliceBounds(len(m.items)) + for i, item := range m.items[start:end] { + if i == m.index%m.height { + s.WriteString(m.cursorStyle.Render(m.cursor)) + } else { + s.WriteString(strings.Repeat(" ", lipgloss.Width(m.cursor))) + } + + if item.selected { + if m.hasTimeout { + timeoutStr = timeout.Str(m.timeout) + } + s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text + timeoutStr)) + } 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)) + } + if i != m.height { + s.WriteRune('\n') + } + } + + if m.paginator.TotalPages > 1 { + s.WriteString(strings.Repeat("\n", m.height-m.paginator.ItemsOnPage(len(m.items))+1)) + s.WriteString(" " + m.paginator.View()) + } + + var parts []string + + if m.header != "" { + parts = append(parts, m.headerStyle.Render(m.header)) + } + parts = append(parts, s.String()) + if m.showHelp { + parts = append(parts, m.help.View(m.keymap)) + } + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func clamp(x, low, high int) int { + if x < low { + return low + } + if x > high { + return high + } + return x +} diff --git a/choose/command.go b/choose/command.go index 8a41a1fba..26e6e23e8 100644 --- a/choose/command.go +++ b/choose/command.go @@ -5,10 +5,12 @@ import ( "fmt" "os" "slices" + "sort" "strings" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/huh" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/paginator" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" @@ -17,11 +19,14 @@ import ( "github.com/charmbracelet/gum/internal/stdin" ) -const widthBuffer = 2 - // Run provides a shell script interface for choosing between different through // options. func (o Options) Run() error { + var ( + subduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}) + verySubduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}) + ) + if len(o.Options) <= 0 { input, _ := stdin.Read() if input == "" { @@ -35,116 +40,120 @@ func (o Options) Run() error { return nil } - theme := huh.ThemeCharm() - keymap := huh.NewDefaultKeyMap() - keymap.Quit = key.NewBinding(key.WithKeys("ctrl+c", "ctrl+q")) - options := huh.NewOptions(o.Options...) + // 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.NoLimit { + o.SelectedPrefix = "" + o.UnselectedPrefix = "" + o.CursorPrefix = "" + } - theme.Focused.Base = lipgloss.NewStyle() - theme.Focused.Title = o.HeaderStyle.ToLipgloss() - theme.Focused.SelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor) - theme.Focused.MultiSelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor) - theme.Focused.SelectedOption = o.SelectedItemStyle.ToLipgloss() - theme.Focused.UnselectedOption = o.ItemStyle.ToLipgloss() - theme.Focused.SelectedPrefix = o.SelectedItemStyle.ToLipgloss().SetString(o.SelectedPrefix) - theme.Focused.UnselectedPrefix = o.ItemStyle.ToLipgloss().SetString(o.UnselectedPrefix) + if o.NoLimit { + o.Limit = len(o.Options) + 1 + } if o.Ordered { - slices.SortFunc(options, func(a, b huh.Option[string]) int { - return strings.Compare(a.Key, b.Key) - }) + slices.SortFunc(o.Options, strings.Compare) } - for _, s := range o.Selected { - for i, opt := range options { - if s == opt.Key || s == opt.Value { - options[i] = opt.Selected(true) + // Keep track of the selected items. + currentSelected := 0 + // Check if selected items should be used. + hasSelectedItems := len(o.Selected) > 0 + startingIndex := 0 + currentOrder := 0 + items := make([]item, len(o.Options)) + for i, option := range o.Options { + var order int + // Check if the option should be selected. + isSelected := hasSelectedItems && currentSelected < o.Limit && slices.Contains(o.Selected, option) + // If the option is selected then increment the current selected count. + if isSelected { + if o.Limit == 1 { + // When the user can choose only one option don't select the option but + // start with the cursor hovering over it. + startingIndex = i + isSelected = false + } else { + currentSelected++ + order = currentOrder + currentOrder++ } } + items[i] = item{text: option, selected: isSelected, order: order} } - width := max(widest(o.Options)+ - max(lipgloss.Width(o.SelectedPrefix)+lipgloss.Width(o.UnselectedPrefix))+ - lipgloss.Width(o.Cursor)+1, lipgloss.Width(o.Header)+widthBuffer) - - if o.NoLimit { - o.Limit = 0 + // Use the pagination model to display the current and total number of + // pages. + pager := paginator.New() + pager.SetTotalPages((len(items) + o.Height - 1) / o.Height) + pager.PerPage = o.Height + pager.Type = paginator.Dots + pager.ActiveDot = subduedStyle.Render("•") + pager.InactiveDot = verySubduedStyle.Render("•") + pager.KeyMap = paginator.KeyMap{} + pager.Page = startingIndex / o.Height + + km := defaultKeymap() + if o.NoLimit || o.Limit > 1 { + km.Toggle.SetEnabled(true) } - - if o.Limit > 1 || o.NoLimit { - var choices []string - - field := huh.NewMultiSelect[string](). - Options(options...). - Title(o.Header). - Height(o.Height). - Limit(o.Limit). - Value(&choices) - - form := huh.NewForm(huh.NewGroup(field)) - - err := form. - WithWidth(width). - WithShowHelp(o.ShowHelp). - WithTheme(theme). - WithKeyMap(keymap). - WithTimeout(o.Timeout). - Run() - if err != nil { - return exit.Handle(err, o.Timeout) - } - if len(choices) > 0 { - s := strings.Join(choices, "\n") - ansiprint(s) - } - return nil + if o.NoLimit { + km.ToggleAll.SetEnabled(true) } - var choice string - - err := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Options(options...). - Title(o.Header). - Height(o.Height). - Value(&choice), - ), - ). - WithWidth(width). - WithTheme(theme). - WithKeyMap(keymap). - WithTimeout(o.Timeout). - WithShowHelp(o.ShowHelp). - Run() + // Disable Keybindings since we will control it ourselves. + tm, err := tea.NewProgram(model{ + index: startingIndex, + currentOrder: currentOrder, + height: o.Height, + cursor: o.Cursor, + header: o.Header, + selectedPrefix: o.SelectedPrefix, + unselectedPrefix: o.UnselectedPrefix, + cursorPrefix: o.CursorPrefix, + items: items, + limit: o.Limit, + paginator: pager, + cursorStyle: o.CursorStyle.ToLipgloss(), + headerStyle: o.HeaderStyle.ToLipgloss(), + itemStyle: o.ItemStyle.ToLipgloss(), + selectedItemStyle: o.SelectedItemStyle.ToLipgloss(), + numSelected: currentSelected, + hasTimeout: o.Timeout > 0, + timeout: o.Timeout, + showHelp: o.ShowHelp, + help: help.New(), + keymap: km, + }, tea.WithOutput(os.Stderr)).Run() if err != nil { - return exit.Handle(err, o.Timeout) + return fmt.Errorf("failed to start tea program: %w", err) } - - if term.IsTerminal(os.Stdout.Fd()) { - fmt.Println(choice) - } else { - fmt.Print(ansi.Strip(choice)) + m := tm.(model) + if m.aborted { + return exit.ErrAborted } - - return nil -} - -func widest(options []string) int { - var maxw int - for _, o := range options { - w := lipgloss.Width(o) - if w > maxw { - maxw = w + if m.timedOut { + return exit.ErrTimeout + } + if o.Ordered && o.Limit > 1 { + sort.Slice(m.items, func(i, j int) bool { + return m.items[i].order < m.items[j].order + }) + } + var s strings.Builder + for _, item := range m.items { + if item.selected { + s.WriteString(item.text) + s.WriteRune('\n') } } - return maxw -} -func ansiprint(s string) { if term.IsTerminal(os.Stdout.Fd()) { - fmt.Println(s) + fmt.Print(s.String()) } else { - fmt.Print(ansi.Strip(s)) + fmt.Print(ansi.Strip(s.String())) } + + return nil } diff --git a/choose/options.go b/choose/options.go index b02b05dd8..ff11c92cf 100644 --- a/choose/options.go +++ b/choose/options.go @@ -12,9 +12,9 @@ type Options struct { 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"` Ordered bool `help:"Maintain the order of the selected options" env:"GUM_CHOOSE_ORDERED"` - Height int `help:"Height of the list" default:"0" env:"GUM_CHOOSE_HEIGHT"` + Height int `help:"Height of the list" default:"10" env:"GUM_CHOOSE_HEIGHT"` Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> " env:"GUM_CHOOSE_CURSOR"` - ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" env:"GUM_CHOOSE_SHOW_HELP"` + ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_CHOOSE_SHOW_HELP"` Header string `help:"Header value" default:"Choose:" env:"GUM_CHOOSE_HEADER"` 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"` diff --git a/confirm/command.go b/confirm/command.go index 3653502d3..e31dd9fa7 100644 --- a/confirm/command.go +++ b/confirm/command.go @@ -1,42 +1,47 @@ package confirm import ( + "errors" "os" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/gum/internal/exit" - "github.com/charmbracelet/huh" + + tea "github.com/charmbracelet/bubbletea" ) // Run provides a shell script interface for prompting a user to confirm an // action with an affirmative or negative answer. func (o Options) Run() error { - theme := huh.ThemeCharm() - theme.Focused.Title = o.PromptStyle.ToLipgloss() - theme.Focused.FocusedButton = o.SelectedStyle.ToLipgloss() - theme.Focused.BlurredButton = o.UnselectedStyle.ToLipgloss() - - choice := o.Default - - err := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Affirmative(o.Affirmative). - Negative(o.Negative). - Title(o.Prompt). - Value(&choice), - ), - ). - WithTimeout(o.Timeout). - WithTheme(theme). - WithShowHelp(o.ShowHelp). - Run() + tm, err := tea.NewProgram(model{ + affirmative: o.Affirmative, + negative: o.Negative, + confirmation: o.Default, + defaultSelection: o.Default, + timeout: o.Timeout, + hasTimeout: o.Timeout > 0, + keys: defaultKeymap(o.Affirmative, o.Negative), + help: help.New(), + showHelp: o.ShowHelp, + prompt: o.Prompt, + selectedStyle: o.SelectedStyle.ToLipgloss(), + unselectedStyle: o.UnselectedStyle.ToLipgloss(), + promptStyle: o.PromptStyle.ToLipgloss(), + }, tea.WithOutput(os.Stderr)).Run() if err != nil { - return exit.Handle(err, o.Timeout) + return err } - if !choice { - os.Exit(1) + m := tm.(model) + if m.timedOut { + return exit.ErrTimeout + } + if m.aborted { + return exit.ErrAborted + } + if m.confirmation { + return nil } - return nil + return errors.New("not confirmed") } diff --git a/confirm/confirm.go b/confirm/confirm.go new file mode 100644 index 000000000..c258068f1 --- /dev/null +++ b/confirm/confirm.go @@ -0,0 +1,194 @@ +// Package confirm provides an interface to ask a user to confirm an action. +// The user is provided with an interface to choose an affirmative or negative +// answer, which is then reflected in the exit code for use in scripting. +// +// If the user selects the affirmative answer, the program exits with 0. If the +// user selects the negative answer, the program exits with 1. +// +// I.e. confirm if the user wants to delete a file +// +// $ gum confirm "Are you sure?" && rm file.txt +package confirm + +import ( + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/gum/timeout" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func defaultKeymap(affirmative, negative string) keymap { + return keymap{ + Abort: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "cancel"), + ), + Quit: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "quit"), + ), + Negative: key.NewBinding( + key.WithKeys("n", "N", "q"), + key.WithHelp("n", negative), + ), + Affirmative: key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", affirmative), + ), + Toggle: key.NewBinding( + key.WithKeys( + "left", + "h", + "ctrl+n", + "shift+tab", + "right", + "l", + "ctrl+p", + "tab", + ), + key.WithHelp("←/→", "toggle"), + ), + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "submit"), + ), + } +} + +type keymap struct { + Abort key.Binding + Quit key.Binding + Negative key.Binding + Affirmative key.Binding + Toggle key.Binding + Submit key.Binding +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{k.Toggle, k.Submit, k.Affirmative, k.Negative} +} + +type model struct { + prompt string + affirmative string + negative string + quitting bool + aborted bool + hasTimeout bool + showHelp bool + help help.Model + keys keymap + timeout time.Duration + + confirmation bool + timedOut bool + + defaultSelection bool + + // styles + promptStyle lipgloss.Style + selectedStyle lipgloss.Style + unselectedStyle lipgloss.Style +} + +func (m model) Init() tea.Cmd { + return timeout.Init(m.timeout, m.defaultSelection) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m, nil + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Abort): + m.confirmation = false + m.aborted = true + fallthrough + case key.Matches(msg, m.keys.Quit): + m.confirmation = false + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Negative): + m.confirmation = false + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Toggle): + if m.negative == "" { + break + } + m.confirmation = !m.confirmation + case key.Matches(msg, m.keys.Submit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Affirmative): + m.quitting = true + m.confirmation = true + return m, tea.Quit + } + case timeout.TickTimeoutMsg: + if msg.TimeoutValue <= 0 { + m.quitting = true + m.confirmation = m.defaultSelection + m.timedOut = true + return m, tea.Quit + } + + m.timeout = msg.TimeoutValue + return m, timeout.Tick(msg.TimeoutValue, msg.Data) + } + return m, nil +} + +func (m model) View() string { + if m.quitting { + return "" + } + + var aff, neg, timeoutStrYes, timeoutStrNo string + timeoutStrNo = "" + timeoutStrYes = "" + if m.hasTimeout { + if m.defaultSelection { + timeoutStrYes = timeout.Str(m.timeout) + } else { + timeoutStrNo = timeout.Str(m.timeout) + } + } + + if m.confirmation { + aff = m.selectedStyle.Render(m.affirmative + timeoutStrYes) + neg = m.unselectedStyle.Render(m.negative + timeoutStrNo) + } else { + aff = m.unselectedStyle.Render(m.affirmative + timeoutStrYes) + neg = m.selectedStyle.Render(m.negative + timeoutStrNo) + } + + // If the option is intentionally empty, do not show it. + if m.negative == "" { + neg = "" + } + + if m.showHelp { + return lipgloss.JoinVertical( + lipgloss.Left, + m.promptStyle.Render(m.prompt)+"\n", + lipgloss.JoinHorizontal(lipgloss.Left, aff, neg), + "\n"+m.help.View(m.keys), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + m.promptStyle.Render(m.prompt)+"\n", + lipgloss.JoinHorizontal(lipgloss.Left, aff, neg), + ) +} diff --git a/file/command.go b/file/command.go index 672211988..6c6d26e29 100644 --- a/file/command.go +++ b/file/command.go @@ -3,11 +3,13 @@ package file import ( "errors" "fmt" + "os" "path/filepath" + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/exit" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" ) // Run is the interface to picking a file. @@ -25,42 +27,50 @@ func (o Options) Run() error { return fmt.Errorf("file not found: %w", err) } - theme := huh.ThemeCharm() - theme.Focused.Base = lipgloss.NewStyle() - theme.Focused.File = o.FileStyle.ToLipgloss() - theme.Focused.Directory = o.DirectoryStyle.ToLipgloss() - theme.Focused.SelectedOption = o.SelectedStyle.ToLipgloss() - - keymap := huh.NewDefaultKeyMap() - keymap.FilePicker.Open.SetEnabled(false) - - // XXX: These should be file selected specific. - theme.Focused.TextInput.Placeholder = o.PermissionsStyle.ToLipgloss() - theme.Focused.TextInput.Prompt = o.CursorStyle.ToLipgloss() + fp := filepicker.New() + fp.CurrentDirectory = path + fp.Path = path + fp.Height = o.Height + fp.AutoHeight = o.Height == 0 + fp.Cursor = o.Cursor + fp.DirAllowed = o.Directory + fp.FileAllowed = o.File + fp.ShowPermissions = o.Permissions + fp.ShowSize = o.Size + fp.ShowHidden = o.All + fp.Styles = filepicker.DefaultStyles() + fp.Styles.Cursor = o.CursorStyle.ToLipgloss() + fp.Styles.Symlink = o.SymlinkStyle.ToLipgloss() + fp.Styles.Directory = o.DirectoryStyle.ToLipgloss() + fp.Styles.File = o.FileStyle.ToLipgloss() + fp.Styles.Permission = o.PermissionsStyle.ToLipgloss() + fp.Styles.Selected = o.SelectedStyle.ToLipgloss() + fp.Styles.FileSize = o.FileSizeStyle.ToLipgloss() + m := model{ + filepicker: fp, + timeout: o.Timeout, + hasTimeout: o.Timeout > 0, + aborted: false, + showHelp: o.ShowHelp, + help: help.New(), + keymap: defaultKeymap(), + } - err = huh.NewForm( - huh.NewGroup( - huh.NewFilePicker(). - Picking(true). - CurrentDirectory(path). - Cursor(o.Cursor). - DirAllowed(o.Directory). - FileAllowed(o.File). - Height(o.Height). - ShowHidden(o.All). - ShowSize(o.Size). - ShowPermissions(o.Permissions). - Value(&path), - ), - ). - WithTimeout(o.Timeout). - WithShowHelp(o.ShowHelp). - WithKeyMap(keymap). - WithTheme(theme). - Run() + tm, err := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run() if err != nil { - return exit.Handle(err, o.Timeout) + return fmt.Errorf("unable to pick selection: %w", err) + } + m = tm.(model) + if m.aborted { + return exit.ErrAborted } - fmt.Println(path) + if m.timedOut { + return exit.ErrTimeout + } + if m.selectedPath == "" { + return errors.New("no file selected") + } + + fmt.Println(m.selectedPath) return nil } diff --git a/file/file.go b/file/file.go new file mode 100644 index 000000000..b32776800 --- /dev/null +++ b/file/file.go @@ -0,0 +1,125 @@ +// Package file provides an interface to pick a file from a folder (tree). +// The user is provided a file manager-like interface to navigate, to +// select a file. +// +// Let's pick a file from the current directory: +// +// $ gum file +// $ gum file . +// +// Let's pick a file from the home directory: +// +// $ gum file $HOME +package file + +import ( + "time" + + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/timeout" + "github.com/charmbracelet/lipgloss" +) + +type keymap filepicker.KeyMap + +var keyQuit = key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc", "close"), +) + +var keyAbort = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "abort"), +) + +func defaultKeymap() keymap { + km := filepicker.DefaultKeyMap() + km.Down.SetHelp("↓", "down") + km.Up.SetHelp("↑", "up") + return keymap(km) +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + keyQuit, + k.Select, + } +} + +type model struct { + filepicker filepicker.Model + selectedPath string + aborted bool + timedOut bool + quitting bool + timeout time.Duration + hasTimeout bool + showHelp bool + help help.Model + keymap keymap +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + timeout.Init(m.timeout, nil), + m.filepicker.Init(), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.showHelp { + m.filepicker.Height -= lipgloss.Height(m.helpView()) + } + case tea.KeyMsg: + switch { + case key.Matches(msg, keyAbort): + m.aborted = true + m.quitting = true + return m, tea.Quit + case key.Matches(msg, keyQuit): + m.quitting = true + return m, tea.Quit + } + case timeout.TickTimeoutMsg: + if msg.TimeoutValue <= 0 { + m.quitting = true + m.timedOut = true + return m, tea.Quit + } + m.timeout = msg.TimeoutValue + return m, timeout.Tick(msg.TimeoutValue, msg.Data) + } + var cmd tea.Cmd + m.filepicker, cmd = m.filepicker.Update(msg) + if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { + m.selectedPath = path + m.quitting = true + return m, tea.Quit + } + return m, cmd +} + +func (m model) View() string { + if m.quitting { + return "" + } + if !m.showHelp { + return m.filepicker.View() + } + return m.filepicker.View() + m.helpView() +} + +func (m model) helpView() string { + return "\n" + m.help.View(m.keymap) +} diff --git a/file/options.go b/file/options.go index 0f8f9af3f..2aa5e5b42 100644 --- a/file/options.go +++ b/file/options.go @@ -19,7 +19,7 @@ type Options struct { Directory bool `help:"Allow directories selection" default:"false" env:"GUM_FILE_DIRECTORY"` ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_FILE_SHOW_HELP"` - Height int `help:"Maximum number of files to display" default:"0" env:"GUM_FILE_HEIGHT"` + Height int `help:"Maximum number of files to display" default:"10" env:"GUM_FILE_HEIGHT"` CursorStyle style.Styles `embed:"" prefix:"cursor." help:"The cursor style" set:"defaultForeground=212" envprefix:"GUM_FILE_CURSOR_"` SymlinkStyle style.Styles `embed:"" prefix:"symlink." help:"The style to use for symlinks" set:"defaultForeground=36" envprefix:"GUM_FILE_SYMLINK_"` DirectoryStyle style.Styles `embed:"" prefix:"directory." help:"The style to use for directories" set:"defaultForeground=99" envprefix:"GUM_FILE_DIRECTORY_"` diff --git a/filter/command.go b/filter/command.go index 5b58940fe..15093add1 100644 --- a/filter/command.go +++ b/filter/command.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -67,9 +68,16 @@ func (o Options) Run() error { matches = matchAll(o.Options) } + km := defaultKeymap() + if o.NoLimit { o.Limit = len(o.Options) } + if o.NoLimit || o.Limit > 1 { + km.Toggle.SetEnabled(true) + km.ToggleAndPrevious.SetEnabled(true) + km.ToggleAndNext.SetEnabled(true) + } p := tea.NewProgram(model{ choices: o.Options, @@ -96,6 +104,9 @@ func (o Options) Run() error { hasTimeout: o.Timeout > 0, sort: o.Sort && o.FuzzySort, strict: o.Strict, + showHelp: o.ShowHelp, + keymap: km, + help: help.New(), }, options...) tm, err := p.Run() @@ -106,6 +117,9 @@ func (o Options) Run() error { if m.aborted { return exit.ErrAborted } + if m.timedOut { + return exit.ErrTimeout + } isTTY := term.IsTerminal(os.Stdout.Fd()) diff --git a/filter/filter.go b/filter/filter.go index b958931b9..956d502f4 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -16,6 +16,8 @@ import ( "github.com/charmbracelet/gum/timeout" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -23,6 +25,67 @@ import ( "github.com/sahilm/fuzzy" ) +func defaultKeymap() keymap { + return keymap{ + Down: key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n"), + key.WithHelp("↓", "down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p"), + key.WithHelp("↑", "up"), + ), + ToggleAndNext: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "toggle"), + key.WithDisabled(), + ), + ToggleAndPrevious: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "toggle"), + key.WithDisabled(), + ), + Toggle: key.NewBinding( + key.WithKeys("ctrl+@"), + key.WithHelp("ctrl+@", "toggle"), + key.WithDisabled(), + ), + Abort: key.NewBinding( + key.WithKeys("ctrl+c", "esc"), + key.WithHelp("ctrl+c", "abort"), + ), + Submit: key.NewBinding( + key.WithKeys("enter", "ctrl+q"), + key.WithHelp("enter", "submit"), + ), + } +} + +type keymap struct { + Down, + Up, + ToggleAndNext, + ToggleAndPrevious, + Toggle, + Abort, + Submit key.Binding +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.ToggleAndNext, + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "navigate"), + ), + k.Submit, + } +} + type model struct { textinput textinput.Model viewport *viewport.Model @@ -38,6 +101,7 @@ type model struct { unselectedPrefix string height int aborted bool + timedOut bool quitting bool headerStyle lipgloss.Style matchStyle lipgloss.Style @@ -49,6 +113,9 @@ type model struct { reverse bool fuzzy bool sort bool + showHelp bool + keymap keymap + help help.Model timeout time.Duration hasTimeout bool strict bool @@ -137,10 +204,18 @@ func (m model) View() string { m.viewport.SetContent(s.String()) + help := "" + if m.showHelp { + help = m.helpView() + } + // View the input and the filtered choices header := m.headerStyle.Render(m.header) if m.reverse { view := m.viewport.View() + "\n" + m.textinput.View() + if m.showHelp { + view += help + } if m.header != "" { return lipgloss.JoinVertical(lipgloss.Left, view, header) } @@ -149,19 +224,26 @@ func (m model) View() string { } view := m.textinput.View() + "\n" + m.viewport.View() + if m.showHelp { + view += help + } if m.header != "" { return lipgloss.JoinVertical(lipgloss.Left, header, view) } return view } +func (m model) helpView() string { + return "\n\n" + m.help.View(m.keymap) +} + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case timeout.TickTimeoutMsg: if msg.TimeoutValue <= 0 { m.quitting = true - m.aborted = true + m.timedOut = true return m, tea.Quit } m.timeout = msg.TimeoutValue @@ -171,41 +253,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.height == 0 || m.height > msg.Height { m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View()) } - - // Make place in the view port if header is set + // Include the header in the height calculation. if m.header != "" { m.viewport.Height = m.viewport.Height - lipgloss.Height(m.headerStyle.Render(m.header)) } + // Include the help in the total height calculation. + if m.showHelp { + m.viewport.Height = m.viewport.Height - lipgloss.Height(m.helpView()) + } m.viewport.Width = msg.Width if m.reverse { m.viewport.YOffset = clamp(0, len(m.matches), len(m.matches)-m.viewport.Height) } case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": + km := m.keymap + switch { + case key.Matches(msg, km.Abort): m.aborted = true m.quitting = true return m, tea.Quit - case "enter": + case key.Matches(msg, km.Submit): m.quitting = true return m, tea.Quit - case "ctrl+n", "ctrl+j", "down": + case key.Matches(msg, km.Down): m.CursorDown() - case "ctrl+p", "ctrl+k", "up": + case key.Matches(msg, km.Up): m.CursorUp() - case "tab": + case key.Matches(msg, km.ToggleAndNext): if m.limit == 1 { break // no op } m.ToggleSelection() m.CursorDown() - case "shift+tab": + case key.Matches(msg, km.ToggleAndPrevious): if m.limit == 1 { break // no op } m.ToggleSelection() m.CursorUp() - case "ctrl+@": + case key.Matches(msg, km.Toggle): if m.limit == 1 { break // no op } diff --git a/filter/options.go b/filter/options.go index 62fb057d6..3cdefe8fe 100644 --- a/filter/options.go +++ b/filter/options.go @@ -15,12 +15,13 @@ type Options struct { 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"` SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` + ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_FILTER_SHOW_HELP"` Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"" default:"true" 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_"` - HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_FILTER_HEADER_"` + HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILTER_HEADER_"` Header string `help:"Header value" default:"" env:"GUM_FILTER_HEADER"` TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"` CursorTextStyle style.Styles `embed:"" prefix:"cursor-text." envprefix:"GUM_FILTER_CURSOR_TEXT_"` diff --git a/go.mod b/go.mod index e512cbaa4..cb29197a7 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,10 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/glamour v0.8.0 - github.com/charmbracelet/huh v0.6.1-0.20241125235914-50e7b0ecd1da github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/log v0.4.0 github.com/charmbracelet/x/ansi v0.5.2 + github.com/charmbracelet/x/editor v0.1.0 github.com/charmbracelet/x/term v0.2.1 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 @@ -24,8 +24,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -36,7 +34,6 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect diff --git a/go.sum b/go.sum index 0921415b3..28e9e2ca8 100644 --- a/go.sum +++ b/go.sum @@ -18,26 +18,22 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -github.com/charmbracelet/huh v0.6.1-0.20241125235914-50e7b0ecd1da h1:q3WNIaHjiKcE3NrTeRfoQngMTvTUezIau0iF3T1sE4A= -github.com/charmbracelet/huh v0.6.1-0.20241125235914-50e7b0ecd1da/go.mod h1:zBQ8egHPGjAW+/mEDhuBq25FRmd+R6bLTDPqLMvyH7s= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/ansi v0.5.2 h1:dEa1x2qdOZXD/6439s+wF7xjV+kZLu/iN00GuXXrU9E= github.com/charmbracelet/x/ansi v0.5.2/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= +github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -67,8 +63,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/input/command.go b/input/command.go index d35515eac..9ce22caad 100644 --- a/input/command.go +++ b/input/command.go @@ -4,11 +4,11 @@ import ( "fmt" "os" - "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/gum/cursor" "github.com/charmbracelet/gum/internal/exit" "github.com/charmbracelet/gum/internal/stdin" ) @@ -16,52 +16,58 @@ import ( // Run provides a shell script interface for the text input bubble. // https://github.com/charmbracelet/bubbles/textinput func (o Options) Run() error { - var value string + if o.Value == "" { + if in, _ := stdin.Read(); in != "" { + o.Value = in + } + } + + i := textinput.New() if o.Value != "" { - value = o.Value + i.SetValue(o.Value) } else if in, _ := stdin.Read(); in != "" { - value = in + i.SetValue(in) } + i.Focus() + i.Prompt = o.Prompt + i.Placeholder = o.Placeholder + i.Width = o.Width + i.PromptStyle = o.PromptStyle.ToLipgloss() + i.PlaceholderStyle = o.PlaceholderStyle.ToLipgloss() + i.Cursor.Style = o.CursorStyle.ToLipgloss() + i.Cursor.SetMode(cursor.Modes[o.CursorMode]) + i.CharLimit = o.CharLimit - theme := huh.ThemeCharm() - theme.Focused.Base = lipgloss.NewStyle() - theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss() - theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss() - theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss() - theme.Focused.Title = o.HeaderStyle.ToLipgloss() - - // Keep input keymap backwards compatible - keymap := huh.NewDefaultKeyMap() - keymap.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc")) - - echoMode := huh.EchoModeNormal if o.Password { - echoMode = huh.EchoModePassword + i.EchoMode = textinput.EchoPassword + i.EchoCharacter = '•' } - err := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Prompt(o.Prompt). - Placeholder(o.Placeholder). - CharLimit(o.CharLimit). - EchoMode(echoMode). - Title(o.Header). - Value(&value), - ), - ). - WithShowHelp(false). - WithWidth(o.Width). - WithTheme(theme). - WithKeyMap(keymap). - WithTimeout(o.Timeout). - WithShowHelp(o.ShowHelp). - WithProgramOptions(tea.WithOutput(os.Stderr)). - Run() + p := tea.NewProgram(model{ + textinput: i, + aborted: false, + header: o.Header, + headerStyle: o.HeaderStyle.ToLipgloss(), + timeout: o.Timeout, + hasTimeout: o.Timeout > 0, + autoWidth: o.Width < 1, + showHelp: o.ShowHelp, + help: help.New(), + keymap: defaultKeymap(), + }, tea.WithOutput(os.Stderr)) + tm, err := p.Run() if err != nil { - return exit.Handle(err, o.Timeout) + return fmt.Errorf("failed to run input: %w", err) + } + + m := tm.(model) + if m.aborted { + return exit.ErrAborted + } + if m.timedOut { + return exit.ErrTimeout } - fmt.Println(value) + fmt.Println(m.textinput.Value()) return nil } diff --git a/input/input.go b/input/input.go new file mode 100644 index 000000000..cb969ede3 --- /dev/null +++ b/input/input.go @@ -0,0 +1,112 @@ +// Package input provides a shell script interface for the text input bubble. +// https://github.com/charmbracelet/bubbles/tree/master/textinput +// +// It can be used to prompt the user for some input. The text the user entered +// will be sent to stdout. +// +// $ gum input --placeholder "What's your favorite gum?" > answer.text +package input + +import ( + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/timeout" + "github.com/charmbracelet/lipgloss" +) + +type keymap textinput.KeyMap + +func defaultKeymap() keymap { + k := textinput.DefaultKeyMap + return keymap(k) +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "submit"), + ), + } +} + +type model struct { + autoWidth bool + header string + headerStyle lipgloss.Style + textinput textinput.Model + quitting bool + timedOut bool + aborted bool + timeout time.Duration + hasTimeout bool + showHelp bool + help help.Model + keymap keymap +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + textinput.Blink, + timeout.Init(m.timeout, nil), + ) +} + +func (m model) View() string { + if m.quitting { + return "" + } + if m.header != "" { + header := m.headerStyle.Render(m.header) + return lipgloss.JoinVertical(lipgloss.Left, header, m.textinput.View()) + } + + if !m.showHelp { + return m.textinput.View() + } + return lipgloss.JoinVertical( + lipgloss.Top, + m.textinput.View(), + "", + m.help.View(m.keymap), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case timeout.TickTimeoutMsg: + if msg.TimeoutValue <= 0 { + m.quitting = true + m.timedOut = true + return m, tea.Quit + } + m.timeout = msg.TimeoutValue + return m, timeout.Tick(msg.TimeoutValue, msg.Data) + case tea.WindowSizeMsg: + if m.autoWidth { + m.textinput.Width = msg.Width - lipgloss.Width(m.textinput.Prompt) - 1 + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.quitting = true + m.aborted = true + return m, tea.Quit + case "enter": + m.quitting = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd +} diff --git a/input/options.go b/input/options.go index a89b0e0f2..2df85e738 100644 --- a/input/options.go +++ b/input/options.go @@ -18,7 +18,7 @@ type Options struct { CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"` Width int `help:"Input width (0 for terminal width)" default:"0" env:"GUM_INPUT_WIDTH"` Password bool `help:"Mask input characters" default:"false"` - ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" env:"GUM_INPUT_SHOW_HELP"` + ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_INPUT_SHOW_HELP"` Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"` Timeout time.Duration `help:"Timeout until input aborts" default:"0s" env:"GUM_INPUT_TIMEOUT"` diff --git a/internal/exit/exit.go b/internal/exit/exit.go index e79d7c22f..fb99b73d7 100644 --- a/internal/exit/exit.go +++ b/internal/exit/exit.go @@ -2,10 +2,7 @@ package exit import ( "errors" - "fmt" - "time" - - "github.com/charmbracelet/huh" + "strconv" ) // StatusTimeout is the exit code for timed out commands. @@ -15,12 +12,13 @@ const StatusTimeout = 124 const StatusAborted = 130 // ErrAborted is the error to return when a gum command is aborted by Ctrl+C. -var ErrAborted = huh.ErrUserAborted +var ErrAborted = errors.New("user aborted") + +// ErrTimeout is the error returned when the timeout is reached. +var ErrTimeout = errors.New("timeout") + +// ErrExit is a custom exit error. +type ErrExit int -// Handle handles the error. -func Handle(err error, d time.Duration) error { - if errors.Is(err, huh.ErrTimeout) { - return fmt.Errorf("%w after %s", huh.ErrTimeout, d) - } - return err -} +// Error implements error. +func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) } diff --git a/main.go b/main.go index 301baefde..fd872edf6 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "runtime/debug" "github.com/alecthomas/kong" - "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" @@ -73,11 +72,15 @@ func main() { }, ) if err := ctx.Run(); err != nil { - if errors.Is(err, huh.ErrTimeout) { + var ex exit.ErrExit + if errors.As(err, &ex) { + os.Exit(int(ex)) + } + if errors.Is(err, exit.ErrTimeout) { fmt.Fprintln(os.Stderr, err) os.Exit(exit.StatusTimeout) } - if errors.Is(err, huh.ErrUserAborted) { + if errors.Is(err, exit.ErrAborted) { os.Exit(exit.StatusAborted) } fmt.Fprintln(os.Stderr, err) diff --git a/pager/command.go b/pager/command.go index 5131b96e3..1bdf1537a 100644 --- a/pager/command.go +++ b/pager/command.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/internal/exit" "github.com/charmbracelet/gum/internal/stdin" ) @@ -29,7 +30,7 @@ func (o Options) Run() error { } } - model := model{ + tm, err := tea.NewProgram(model{ viewport: vp, helpStyle: o.HelpStyle.ToLipgloss(), content: o.Content, @@ -41,10 +42,15 @@ func (o Options) Run() error { matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(), timeout: o.Timeout, hasTimeout: o.Timeout > 0, - } - _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() + }, tea.WithAltScreen()).Run() if err != nil { return fmt.Errorf("unable to start program: %w", err) } + + m := tm.(model) + if m.timedOut { + return exit.ErrTimeout + } + return nil } diff --git a/pager/pager.go b/pager/pager.go index f55201bd5..38e88d30e 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -30,6 +30,7 @@ type model struct { maxWidth int timeout time.Duration hasTimeout bool + timedOut bool } func (m model) Init() tea.Cmd { @@ -40,6 +41,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case timeout.TickTimeoutMsg: if msg.TimeoutValue <= 0 { + m.timedOut = true return m, tea.Quit } m.timeout = msg.TimeoutValue diff --git a/spin/command.go b/spin/command.go index 6bde58dcf..a706c4077 100644 --- a/spin/command.go +++ b/spin/command.go @@ -19,7 +19,7 @@ func (o Options) Run() error { s := spinner.New() s.Style = o.SpinnerStyle.ToLipgloss() s.Spinner = spinnerMap[o.Spinner] - m := model{ + tm, err := tea.NewProgram(model{ spinner: s, title: o.TitleStyle.ToLipgloss().Render(o.Title), command: o.Command, @@ -28,18 +28,18 @@ func (o Options) Run() error { showError: o.ShowError, timeout: o.Timeout, hasTimeout: o.Timeout > 0, - } - p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) - mm, err := p.Run() - m = mm.(model) - + }, tea.WithOutput(os.Stderr)).Run() if err != nil { return fmt.Errorf("failed to run spin: %w", err) } + m := tm.(model) if m.aborted { return exit.ErrAborted } + if m.timedOut { + return exit.ErrTimeout + } // If the command succeeds, and we are printing output and we are in a TTY then push the STDOUT we got to the actual // STDOUT for piping or other things. @@ -64,6 +64,5 @@ func (o Options) Run() error { } } - os.Exit(m.status) - return nil + return exit.ErrExit(m.status) } diff --git a/spin/spin.go b/spin/spin.go index 9037cb099..30fd95d95 100644 --- a/spin/spin.go +++ b/spin/spin.go @@ -22,7 +22,6 @@ import ( "syscall" "time" - "github.com/charmbracelet/gum/internal/exit" "github.com/charmbracelet/gum/timeout" "github.com/charmbracelet/x/term" @@ -37,6 +36,7 @@ type model struct { command []string quitting bool aborted bool + timedOut bool status int stdout string stderr string @@ -134,8 +134,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.TimeoutValue <= 0 { // grab current output before closing for piped instances m.stdout = outbuf.String() - - m.status = exit.StatusAborted + m.timedOut = true return m, tea.Quit } m.timeout = msg.TimeoutValue diff --git a/write/command.go b/write/command.go index d07e2e7a6..97a806f77 100644 --- a/write/command.go +++ b/write/command.go @@ -2,10 +2,16 @@ package write import ( "fmt" + "os" "strings" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + + "github.com/charmbracelet/gum/cursor" + "github.com/charmbracelet/gum/internal/exit" "github.com/charmbracelet/gum/internal/stdin" - "github.com/charmbracelet/huh" ) // Run provides a shell script interface for the text area bubble. @@ -16,39 +22,51 @@ func (o Options) Run() error { o.Value = strings.ReplaceAll(in, "\r", "") } - var value = o.Value - - theme := huh.ThemeCharm() - theme.Focused.Base = o.BaseStyle.ToLipgloss() - theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss() - theme.Focused.Title = o.HeaderStyle.ToLipgloss() - theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss() - theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss() - - keymap := huh.NewDefaultKeyMap() - keymap.Text.NewLine.SetHelp("ctrl+j", "new line") - - err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title(o.Header). - Placeholder(o.Placeholder). - CharLimit(o.CharLimit). - ShowLineNumbers(o.ShowLineNumbers). - Value(&value), - ), - ). - WithWidth(o.Width). - WithHeight(o.Height). - WithTheme(theme). - WithKeyMap(keymap). - WithShowHelp(o.ShowHelp). - Run() + a := textarea.New() + a.Focus() + + a.Prompt = o.Prompt + a.Placeholder = o.Placeholder + a.ShowLineNumbers = o.ShowLineNumbers + a.CharLimit = o.CharLimit + + style := textarea.Style{ + Base: o.BaseStyle.ToLipgloss(), + Placeholder: o.PlaceholderStyle.ToLipgloss(), + CursorLine: o.CursorLineStyle.ToLipgloss(), + CursorLineNumber: o.CursorLineNumberStyle.ToLipgloss(), + EndOfBuffer: o.EndOfBufferStyle.ToLipgloss(), + LineNumber: o.LineNumberStyle.ToLipgloss(), + Prompt: o.PromptStyle.ToLipgloss(), + } + a.BlurredStyle = style + a.FocusedStyle = style + a.Cursor.Style = o.CursorStyle.ToLipgloss() + a.Cursor.SetMode(cursor.Modes[o.CursorMode]) + + a.SetWidth(o.Width) + a.SetHeight(o.Height) + a.SetValue(o.Value) + + p := tea.NewProgram(model{ + textarea: a, + header: o.Header, + headerStyle: o.HeaderStyle.ToLipgloss(), + autoWidth: o.Width < 1, + help: help.New(), + showHelp: o.ShowHelp, + keymap: defaultKeymap(), + }, tea.WithOutput(os.Stderr), tea.WithReportFocus()) + tm, err := p.Run() if err != nil { - return err + return fmt.Errorf("failed to run write: %w", err) + } + m := tm.(model) + if m.aborted { + return exit.ErrAborted } - fmt.Println(value) + fmt.Println(m.textarea.Value()) return nil } diff --git a/write/options.go b/write/options.go index 91b6596b2..0e33cd5a6 100644 --- a/write/options.go +++ b/write/options.go @@ -16,14 +16,13 @@ type Options struct { ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_WRITE_SHOW_HELP"` CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_WRITE_CURSOR_MODE"` - BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` - CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"` - HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"` - PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"` - PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"` - - EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"` - LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"` + BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"` CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"` + CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"` + EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"` + LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"` + HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"` + PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"` + PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"` } diff --git a/write/write.go b/write/write.go new file mode 100644 index 000000000..4b242340d --- /dev/null +++ b/write/write.go @@ -0,0 +1,189 @@ +// Package write provides a shell script interface for the text area bubble. +// https://github.com/charmbracelet/bubbles/tree/master/textarea +// +// It can be used to ask the user to write some long form of text (multi-line) +// input. The text the user entered will be sent to stdout. +// Text entry is completed with CTRL+D and aborted with CTRL+C or Escape. +// +// $ gum write > output.text +package write + +import ( + "io" + "os" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/editor" +) + +type keymap struct { + textarea.KeyMap + Submit key.Binding + Quit key.Binding + OpenInEditor key.Binding +} + +// FullHelp implements help.KeyMap. +func (k keymap) FullHelp() [][]key.Binding { return nil } + +// ShortHelp implements help.KeyMap. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.InsertNewline, + k.OpenInEditor, + k.Submit, + } +} + +func defaultKeymap() keymap { + km := textarea.DefaultKeyMap + km.InsertNewline = key.NewBinding( + key.WithKeys("ctrl+j"), + key.WithHelp("ctrl+j", "insert newline"), + ) + return keymap{ + KeyMap: km, + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "cancel"), + ), + OpenInEditor: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "submit"), + ), + } +} + +type model struct { + autoWidth bool + aborted bool + header string + headerStyle lipgloss.Style + quitting bool + textarea textarea.Model + showHelp bool + help help.Model + keymap keymap +} + +func (m model) Init() tea.Cmd { return textarea.Blink } +func (m model) View() string { + if m.quitting { + return "" + } + + var parts []string + + // Display the header above the text area if it is not empty. + if m.header != "" { + parts = append(parts, m.headerStyle.Render(m.header)) + } + parts = append(parts, m.textarea.View()) + if m.showHelp { + parts = append(parts, m.help.View(m.keymap)) + } + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.autoWidth { + m.textarea.SetWidth(msg.Width) + } + case tea.FocusMsg, tea.BlurMsg: + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + case startEditorMsg: + return m, openEditor(msg.path, msg.lineno) + case editorFinishedMsg: + if msg.err != nil { + m.aborted = true + m.quitting = true + return m, tea.Quit + } + m.textarea.SetValue(msg.content) + case tea.KeyMsg: + km := m.keymap + switch { + case key.Matches(msg, km.Quit): + m.aborted = true + m.quitting = true + return m, tea.Quit + case key.Matches(msg, km.Submit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, km.OpenInEditor): + //nolint: gosec + return m, createTempFile(m.textarea.Value(), uint(m.textarea.Line())+1) + } + } + + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd +} + +type startEditorMsg struct { + path string + lineno uint +} + +type editorFinishedMsg struct { + content string + err error +} + +func createTempFile(content string, lineno uint) tea.Cmd { + return func() tea.Msg { + f, err := os.CreateTemp("", "gum.*.md") + if err != nil { + return editorFinishedMsg{err: err} + } + _, err = io.WriteString(f, content) + if err != nil { + return editorFinishedMsg{err: err} + } + _ = f.Close() + return startEditorMsg{ + path: f.Name(), + lineno: lineno, + } + } +} + +func openEditor(path string, lineno uint) tea.Cmd { + cb := func(err error) tea.Msg { + if err != nil { + return editorFinishedMsg{ + err: err, + } + } + bts, err := os.ReadFile(path) + if err != nil { + return editorFinishedMsg{err: err} + } + return editorFinishedMsg{ + content: string(bts), + } + } + cmd, err := editor.Cmd( + "Gum", + path, + editor.LineNumber(lineno), + editor.EndOfLine(), + ) + if err != nil { + return func() tea.Msg { return cb(err) } + } + return tea.ExecProcess(cmd, cb) +}