mirror of
https://github.com/charmbracelet/gum
synced 2026-03-14 21:55:45 +01:00
refactor: removing huh as a dep (#742)
* Revert "feat: huh gum write (#525)" This reverts commit4d5d53169e. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * Revert "Use Huh for Gum Confirm (#522)" This reverts commitf7572e387e. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * revert: Use Huh for Gum Choose (#521) Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * revert: feat: huh for gum input (#524) * revert: feat: huh file picker (#523) * feat: remove huh * fix: timeouts * fix: lint issues * fix(choose): quit on ctrl+q ported over63a3e8c8ce* fix: ctrl+a to reverse selection Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: better handle spin exit codes * fix(file): bind --[no-]permissions and --[no-]size * feat(confirm): show help * fix(confirm): fix help style * fix(file): help * fix(input): --no-show-help doesn't work * fix(input): help * fix(file): keymap improvement * fix(write): focus * feat(write): ctrl+e, keymaps, help * feat(choose): help * feat(filter): help * refactor: keymaps * fix(choose): only show 'toggle all' if there's no limit * fix(choose): don't show toggle if the choices are limited to 1 * fix(filter): match choose header color * fix(filter): add space above help * fix(filter): factor help into the height setting * chore(choose,filter): use verb for navigation label in help * fix(filter): hide toggle help if limit is 1 * fix(file): factor help into height setting (#746) * fix: lint issues * fix(file): handle ctrl+c * fix: remove full help * fix: lint --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Christian Rocha <christian@rocha.is>
This commit is contained in:
parent
d74e9ea531
commit
e30fc5ecdf
25 changed files with 1374 additions and 292 deletions
316
choose/choose.go
Normal file
316
choose/choose.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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...)
|
||||
|
||||
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.Ordered {
|
||||
slices.SortFunc(options, func(a, b huh.Option[string]) int {
|
||||
return strings.Compare(a.Key, b.Key)
|
||||
})
|
||||
// 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 = ""
|
||||
}
|
||||
|
||||
for _, s := range o.Selected {
|
||||
for i, opt := range options {
|
||||
if s == opt.Key || s == opt.Value {
|
||||
options[i] = opt.Selected(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
o.Limit = len(o.Options) + 1
|
||||
}
|
||||
|
||||
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.Ordered {
|
||||
slices.SortFunc(o.Options, strings.Compare)
|
||||
}
|
||||
|
||||
var choice string
|
||||
// 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}
|
||||
}
|
||||
|
||||
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()
|
||||
// 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.NoLimit {
|
||||
km.ToggleAll.SetEnabled(true)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
m := tm.(model)
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
if term.IsTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(choice)
|
||||
fmt.Print(s.String())
|
||||
} else {
|
||||
fmt.Print(ansi.Strip(choice))
|
||||
fmt.Print(ansi.Strip(s.String()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func widest(options []string) int {
|
||||
var maxw int
|
||||
for _, o := range options {
|
||||
w := lipgloss.Width(o)
|
||||
if w > maxw {
|
||||
maxw = w
|
||||
}
|
||||
}
|
||||
return maxw
|
||||
}
|
||||
|
||||
func ansiprint(s string) {
|
||||
if term.IsTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(s)
|
||||
} else {
|
||||
fmt.Print(ansi.Strip(s))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
194
confirm/confirm.go
Normal file
194
confirm/confirm.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return exit.Handle(err, o.Timeout)
|
||||
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(),
|
||||
}
|
||||
fmt.Println(path)
|
||||
|
||||
tm, err := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to pick selection: %w", err)
|
||||
}
|
||||
m = tm.(model)
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
if m.timedOut {
|
||||
return exit.ErrTimeout
|
||||
}
|
||||
if m.selectedPath == "" {
|
||||
return errors.New("no file selected")
|
||||
}
|
||||
|
||||
fmt.Println(m.selectedPath)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
125
file/file.go
Normal file
125
file/file.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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_"`
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
108
filter/filter.go
108
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_"`
|
||||
|
|
|
|||
5
go.mod
5
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
|
||||
|
|
|
|||
10
go.sum
10
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
fmt.Println(value)
|
||||
m := tm.(model)
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
if m.timedOut {
|
||||
return exit.ErrTimeout
|
||||
}
|
||||
|
||||
fmt.Println(m.textinput.Value())
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
112
input/input.go
Normal file
112
input/input.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
// 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
|
||||
}
|
||||
// ErrTimeout is the error returned when the timeout is reached.
|
||||
var ErrTimeout = errors.New("timeout")
|
||||
|
||||
// ErrExit is a custom exit error.
|
||||
type ErrExit int
|
||||
|
||||
// Error implements error.
|
||||
func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) }
|
||||
|
|
|
|||
9
main.go
9
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
a := textarea.New()
|
||||
a.Focus()
|
||||
|
||||
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()
|
||||
a.Prompt = o.Prompt
|
||||
a.Placeholder = o.Placeholder
|
||||
a.ShowLineNumbers = o.ShowLineNumbers
|
||||
a.CharLimit = o.CharLimit
|
||||
|
||||
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()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
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(),
|
||||
}
|
||||
|
||||
fmt.Println(value)
|
||||
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 fmt.Errorf("failed to run write: %w", err)
|
||||
}
|
||||
m := tm.(model)
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
|
||||
fmt.Println(m.textarea.Value())
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_"`
|
||||
}
|
||||
|
|
|
|||
189
write/write.go
Normal file
189
write/write.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue