diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cd9f34c..60f9b47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @charmbracelet/everyone +* @maaslalani diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d944991..9fc3b07 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,57 +1,20 @@ version: 2 - updates: - package-ecosystem: "gomod" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "daily" labels: - "dependencies" commit-message: - prefix: "chore" + prefix: "feat" include: "scope" - groups: - all: - patterns: - - "*" - ignore: - - dependency-name: github.com/charmbracelet/bubbletea/v2 - versions: - - v2.0.0-beta1 - - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "daily" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" - groups: - all: - patterns: - - "*" - - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2330819..5a74cbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,34 @@ name: build -on: - push: - branches: - - main - pull_request: +on: [push, pull_request] jobs: build: - uses: charmbracelet/meta/.github/workflows/build.yml@main + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + GO111MODULE: "on" + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ~1.21 + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Go modules + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v -cover -timeout=30s ./... + + snapshot: + uses: charmbracelet/meta/.github/workflows/snapshot.yml@main secrets: - gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + goreleaser_key: ${{ secrets.GORELEASER_KEY }} diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml deleted file mode 100644 index 9b08259..0000000 --- a/.github/workflows/dependabot-sync.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: dependabot-sync -on: - schedule: - - cron: "0 0 * * 0" # every Sunday at midnight - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - dependabot-sync: - uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main - with: - repo_name: ${{ github.event.repository.name }} - secrets: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml new file mode 100644 index 0000000..87d1e1f --- /dev/null +++ b/.github/workflows/lint-soft.yml @@ -0,0 +1,28 @@ +name: lint-soft +on: + push: + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + +jobs: + golangci: + name: lint-soft + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + # Optional: golangci-lint command line arguments. + args: --config .golangci-soft.yml --issues-exit-code=0 + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml deleted file mode 100644 index ecf8580..0000000 --- a/.github/workflows/lint-sync.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: lint-sync -on: - schedule: - # every Sunday at midnight - - cron: "0 0 * * 0" - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a1d6d0e..f617a5a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,26 @@ on: push: pull_request: +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint.yml@main + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + # Optional: golangci-lint command line arguments. + #args: + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true diff --git a/.golangci-soft.yml b/.golangci-soft.yml new file mode 100644 index 0000000..01d7797 --- /dev/null +++ b/.golangci-soft.yml @@ -0,0 +1,46 @@ +run: + tests: false + +issues: + include: + - EXC0001 + - EXC0005 + - EXC0011 + - EXC0012 + - EXC0013 + + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + enable: + # - dupl + - exhaustive + # - exhaustivestruct + - goconst + - godot + - godox + - gomnd + - gomoddirectives + - goprintffuncname + # - ifshort + # - lll + - misspell + - nakedret + - nestif + - noctx + - nolintlint + - prealloc + + # disable default linters, they are already enabled in .golangci.yml + disable: + - wrapcheck + - deadcode + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - structcheck + - typecheck + - varcheck diff --git a/.golangci.yml b/.golangci.yml index c90f031..a5a91d0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,22 +1,25 @@ -version: "2" run: tests: false + +issues: + include: + - EXC0001 + - EXC0005 + - EXC0011 + - EXC0012 + - EXC0013 + + max-issues-per-linter: 0 + max-same-issues: 0 + linters: enable: - bodyclose - - exhaustive - - goconst - - godot - - gomoddirectives - - goprintffuncname + - exportloopref + - goimports - gosec - - misspell - - nakedret - - nestif - nilerr - - noctx - - nolintlint - - prealloc + - predeclared - revive - rowserrcheck - sqlclosecheck @@ -24,24 +27,3 @@ linters: - unconvert - unparam - whitespace - - wrapcheck - exclusions: - rules: - - text: '(slog|log)\.\w+' - linters: - - noctx - generated: lax - presets: - - common-false-positives - settings: - exhaustive: - default-signifies-exhaustive: true -issues: - max-issues-per-linter: 0 - max-same-issues: 0 -formatters: - enable: - - gofumpt - - goimports - exclusions: - generated: lax diff --git a/.goreleaser.yml b/.goreleaser.yml index b478db6..7bad023 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,7 +1,5 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -version: 2 - includes: - from_url: url: charmbracelet/meta/main/goreleaser-full.yaml diff --git a/README.md b/README.md index 80a2b67..120ea16 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Gum +Gum +===
@@ -20,13 +21,11 @@ The above example is running from a single shell script ([source](./examples/dem
## Tutorial
Gum provides highly configurable, ready-to-use utilities to help you write
-useful shell scripts and dotfile aliases with just a few lines of code.
-Let's build a simple script to help you write
-[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary)
+useful shell scripts and dotfiles aliases with just a few lines of code.
+Let's build a simple script to help you write [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary)
for your dotfiles.
Ask for the commit type with gum choose:
-
```bash
gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert"
```
@@ -35,20 +34,17 @@ gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert"
> This command itself will print to stdout which is not all that useful. To make use of the command later on you can save the stdout to a `$VARIABLE` or `file.txt`.
Prompt for the scope of these changes:
-
```bash
gum input --placeholder "scope"
```
Prompt for the summary and description of changes:
-
```bash
gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change"
gum write --placeholder "Details of this change"
```
Confirm before committing:
-
```bash
gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION"
```
@@ -68,9 +64,6 @@ brew install gum
# Arch Linux (btw)
pacman -S gum
-# Fedora or EPEL 10
-dnf install gum
-
# Nix
nix-env -iA nixpkgs.gum
@@ -91,11 +84,10 @@ curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/ke
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update && sudo apt install gum
```
-
Fedora/RHEL/OpenSuse
+Fedora/RHEL
```bash
echo '[charm]
@@ -104,35 +96,14 @@ baseurl=https://repo.charm.sh/yum/
enabled=1
gpgcheck=1
gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
-sudo rpm --import https://repo.charm.sh/yum/gpg.key
-
-# yum
sudo yum install gum
-
-# zypper
-sudo zypper refresh
-sudo zypper install gum
```
-
-FreeBSD
-
-```bash
-# packages
-sudo pkg install gum
-
-# ports
-cd /usr/ports/devel/gum && sudo make install clean
-```
-
@@ -398,87 +369,81 @@ How to use `gum` in your daily workflows:
See the [examples](./examples/) directory for more real world use cases.
-- Write a commit message:
+* Write a commit message:
```bash
git commit -m "$(gum input --width 50 --placeholder "Summary of changes")" \
-m "$(gum write --width 80 --placeholder "Details of changes")"
```
-- Open files in your `$EDITOR`
+* Open files in your `$EDITOR`
```bash
$EDITOR $(gum filter)
```
-- Connect to a `tmux` session
+* Connect to a `tmux` session
```bash
SESSION=$(tmux list-sessions -F \#S | gum filter --placeholder "Pick session...")
-tmux switch-client -t "$SESSION" || tmux attach -t "$SESSION"
+tmux switch-client -t $SESSION || tmux attach -t $SESSION
```
-- Pick a commit hash from `git` history
+* Pick a commit hash from `git` history
```bash
git log --oneline | gum filter | cut -d' ' -f1 # | copy
```
-- Simple [`skate`](https://github.com/charmbracelet/skate) password selector.
+* Simple [`skate`](https://github.com/charmbracelet/skate) password selector.
```
skate list -k | gum filter | xargs skate get
```
-- Uninstall packages
+* Uninstall packages
```bash
brew list | gum choose --no-limit | xargs brew uninstall
```
-- Clean up `git` branches
+* Clean up `git` branches
```bash
git branch | cut -c 3- | gum choose --no-limit | xargs git branch -D
```
-- Checkout GitHub pull requests with [`gh`](https://cli.github.com/)
+* Checkout GitHub pull requests with [`gh`](https://cli.github.com/)
```bash
gh pr list | cut -f1,2 | gum choose | cut -f1 | xargs gh pr checkout
```
-- Copy command from shell history
+* Copy command from shell history
```bash
gum filter < $HISTFILE --height 20
```
-- `sudo` replacement
+* `sudo` replacement
```bash
alias please="gum input --password | sudo -nS"
```
-## Contributing
-
-See [contributing][contribute].
-
-[contribute]: https://github.com/charmbracelet/gum/contribute
-
## Feedback
We’d love to hear your thoughts on this project. Feel free to drop us a note!
-- [Twitter](https://twitter.com/charmcli)
-- [The Fediverse](https://mastodon.social/@charmcli)
-- [Discord](https://charm.sh/chat)
+* [Twitter](https://twitter.com/charmcli)
+* [The Fediverse](https://mastodon.social/@charmcli)
+* [Discord](https://charm.sh/chat)
## License
[MIT](https://github.com/charmbracelet/gum/raw/main/LICENSE)
----
+***
Part of [Charm](https://charm.sh).
diff --git a/choose/choose.go b/choose/choose.go
deleted file mode 100644
index c6be614..0000000
--- a/choose/choose.go
+++ /dev/null
@@ -1,289 +0,0 @@
-// 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"
-
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/paginator"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/exp/ordered"
-)
-
-func defaultKeymap() keymap {
- return keymap{
- Down: key.NewBinding(
- key.WithKeys("down", "j", "ctrl+j", "ctrl+n"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "k", "ctrl+k", "ctrl+p"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right", "l", "ctrl+f"),
- ),
- Left: key.NewBinding(
- key.WithKeys("left", "h", "ctrl+b"),
- ),
- Home: key.NewBinding(
- key.WithKeys("g", "home"),
- ),
- End: key.NewBinding(
- key.WithKeys("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"),
- key.WithHelp("ctrl+c", "abort"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "quit"),
- ),
- Submit: key.NewBinding(
- key.WithKeys("enter", "ctrl+q"),
- key.WithHelp("enter", "submit"),
- ),
- }
-}
-
-type keymap struct {
- Down,
- Up,
- Right,
- Left,
- Home,
- End,
- ToggleAll,
- Toggle,
- Abort,
- Quit,
- 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
- padding []int
- cursor string
- selectedPrefix string
- unselectedPrefix string
- cursorPrefix string
- header string
- items []item
- quitting bool
- submitted bool
- index int
- limit int
- numSelected int
- currentOrder int
- paginator paginator.Model
- showHelp bool
- help help.Model
- keymap keymap
-
- // styles
- cursorStyle lipgloss.Style
- headerStyle lipgloss.Style
- itemStyle lipgloss.Style
- selectedItemStyle lipgloss.Style
-}
-
-type item struct {
- text string
- selected bool
- order int
-}
-
-func (m model) Init() tea.Cmd { return nil }
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- return m, nil
-
- 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 = ordered.Clamp(m.index+m.height, 0, len(m.items)-1)
- m.paginator.NextPage()
- case key.Matches(msg, km.Left):
- m.index = ordered.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.Quit):
- m.quitting = true
- return m, tea.Quit
- case key.Matches(msg, km.Abort):
- m.quitting = true
- return m, tea.Interrupt
- 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
- }
- m.submitted = 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
-
- 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 {
- s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text))
- } else if i == m.index%m.height {
- s.WriteString(m.cursorStyle.Render(m.cursorPrefix + item.text))
- } else {
- s.WriteString(m.itemStyle.Render(m.unselectedPrefix + item.text))
- }
- 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))
- }
-
- view := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(view)
-}
diff --git a/choose/command.go b/choose/command.go
index 70b8a9f..89c25bd 100644
--- a/choose/command.go
+++ b/choose/command.go
@@ -4,179 +4,132 @@ import (
"errors"
"fmt"
"os"
- "slices"
- "sort"
"strings"
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/paginator"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/internal/tty"
- "github.com/charmbracelet/gum/style"
+ "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/term"
+
+ "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"})
- )
-
- input, _ := stdin.Read(stdin.StripANSI(o.StripANSI))
- if len(o.Options) > 0 && len(o.Selected) == 0 {
- o.Selected = strings.Split(input, o.InputDelimiter)
- } else if len(o.Options) == 0 {
+ if len(o.Options) <= 0 {
+ input, _ := stdin.Read()
if input == "" {
return errors.New("no options provided, see `gum choose --help`")
}
- o.Options = strings.Split(input, o.InputDelimiter)
- }
-
- // normalize options into a map
- options := map[string]string{}
- // keep the labels in the user-provided order
- var labels []string //nolint:prealloc
- for _, opt := range o.Options {
- if o.LabelDelimiter == "" {
- options[opt] = opt
- continue
- }
- label, value, ok := strings.Cut(opt, o.LabelDelimiter)
- if !ok {
- return fmt.Errorf("invalid option format: %q", opt)
- }
- labels = append(labels, label)
- options[label] = value
- }
- if o.LabelDelimiter != "" {
- o.Options = labels
+ o.Options = strings.Split(input, "\n")
}
if o.SelectIfOne && len(o.Options) == 1 {
- fmt.Println(options[o.Options[0]])
+ fmt.Println(o.Options[0])
return nil
}
- // 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 := huh.ThemeCharm()
+ options := huh.NewOptions(o.Options...)
- if o.NoLimit {
- o.Limit = len(o.Options) + 1
- }
+ 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(o.Options, strings.Compare)
- }
-
- isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*"
-
- // 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 && (isSelectAll || 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++
+ for _, s := range o.Selected {
+ for i, opt := range options {
+ if s == opt.Key || s == opt.Value {
+ options[i] = opt.Selected(true)
}
}
- items[i] = item{text: option, selected: isSelected, order: order}
}
- // Use the pagination model to display the current and total number of
- // pages.
- top, right, bottom, left := style.ParsePadding(o.Padding)
- 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)
+ o.Limit = len(o.Options)
}
- m := model{
- index: startingIndex,
- currentOrder: currentOrder,
- height: o.Height,
- padding: []int{top, right, bottom, left},
- 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,
- showHelp: o.ShowHelp,
- help: help.New(),
- keymap: km,
- }
+ width := max(widest(o.Options)+
+ max(lipgloss.Width(o.SelectedPrefix)+lipgloss.Width(o.UnselectedPrefix))+
+ lipgloss.Width(o.Cursor)+1, lipgloss.Width(o.Header)+widthBuffer)
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
+ if o.Limit > 1 {
+ var choices []string
- // Disable Keybindings since we will control it ourselves.
- tm, err := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithContext(ctx),
- ).Run()
- if err != nil {
- return fmt.Errorf("unable to pick selection: %w", err)
- }
- m = tm.(model)
- if !m.submitted {
- return errors.New("nothing selected")
- }
- if o.Ordered && o.Limit > 1 {
- sort.Slice(m.items, func(i, j int) bool {
- return m.items[i].order < m.items[j].order
- })
- }
+ field := huh.NewMultiSelect[string]().
+ Options(options...).
+ Title(o.Header).
+ Height(o.Height).
+ Limit(o.Limit).
+ Value(&choices)
- var out []string
- for _, item := range m.items {
- if item.selected {
- out = append(out, options[item.text])
+ form := huh.NewForm(huh.NewGroup(field))
+
+ err := form.
+ WithWidth(width).
+ WithShowHelp(o.ShowHelp).
+ WithTheme(theme).
+ Run()
+ if err != nil {
+ return err
}
+ if len(choices) > 0 {
+ s := strings.Join(choices, "\n")
+ ansiprint(s)
+ }
+ return nil
}
- tty.Println(strings.Join(out, o.OutputDelimiter))
+
+ 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).
+ WithShowHelp(o.ShowHelp).
+ Run()
+ if err != nil {
+ return err
+ }
+
+ if term.IsTerminal(os.Stdout.Fd()) {
+ fmt.Println(choice)
+ } else {
+ fmt.Print(ansi.Strip(choice))
+ }
+
return nil
}
+
+func widest(options []string) int {
+ var max int
+ for _, o := range options {
+ w := lipgloss.Width(o)
+ if w > max {
+ max = w
+ }
+ }
+ return max
+}
+
+func ansiprint(s string) {
+ if term.IsTerminal(os.Stdout.Fd()) {
+ fmt.Println(s)
+ } else {
+ fmt.Print(ansi.Strip(s))
+ }
+}
diff --git a/choose/options.go b/choose/options.go
index abfca22..c9f07bd 100644
--- a/choose/options.go
+++ b/choose/options.go
@@ -8,28 +8,22 @@ import (
// Options is the customization options for the choose command.
type Options struct {
- Options []string `arg:"" optional:"" help:"Options to choose from."`
- 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:"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:"" env:"GUM_CHOOSE_SHOW_HELP"`
- Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_CHOOSE_TIMEOUT"` // including timeout command options [Timeout,...]
- 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"`
- UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"`
- Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_CHOOSE_SELECTED"`
- SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
- InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"`
- OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"`
- LabelDelimiter string `help:"Allows to set a delimiter, so options can be set as label:value" default:"" env:"GUM_CHOOSE_LABEL_DELIMITER"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_CHOOSE_STRIP_ANSI"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_CHOOSE_PADDING"`
-
- CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
- HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"`
- ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
- SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`
+ Options []string `arg:"" optional:"" help:"Options to choose from."`
+ 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:"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"`
+ 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"`
+ UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"`
+ Selected []string `help:"Options that should start as selected" default:"" env:"GUM_CHOOSE_SELECTED"`
+ SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
+ CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
+ HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"`
+ ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
+ SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`
+ Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0" env:"GUM_CCHOOSE_TIMEOUT"` // including timeout command options [Timeout,...]
}
diff --git a/completion/bash.go b/completion/bash.go
index 33d309b..3064ec1 100644
--- a/completion/bash.go
+++ b/completion/bash.go
@@ -1,5 +1,3 @@
-// Package completion provides a bash completion generator for Kong
-// applications.
package completion
import (
@@ -630,7 +628,6 @@ func writeCmdAliases(buf io.StringWriter, cmd *kong.Node) {
writeString(buf, ` fi`)
writeString(buf, "\n")
}
-
func writeArgAliases(buf io.StringWriter, cmd *kong.Node) {
writeString(buf, " noun_aliases=()\n")
sort.Strings(cmd.Aliases)
diff --git a/completion/fish.go b/completion/fish.go
index 9fc80d2..8335356 100644
--- a/completion/fish.go
+++ b/completion/fish.go
@@ -79,7 +79,7 @@ func (f Fish) gen(buf io.StringWriter, cmd *kong.Node) {
_, _ = buf.WriteString(fmt.Sprintf(" -s %c", f.Short))
}
_, _ = buf.WriteString(fmt.Sprintf(" -l %s", f.Name))
- _, _ = buf.WriteString(fmt.Sprintf(" -d \"%s\"", f.Help))
+ _, _ = buf.WriteString(fmt.Sprintf(" -d '%s'", f.Help))
_, _ = buf.WriteString("\n")
}
_, _ = buf.WriteString("\n")
diff --git a/confirm/command.go b/confirm/command.go
index fc0a02b..30fd51d 100644
--- a/confirm/command.go
+++ b/confirm/command.go
@@ -1,71 +1,42 @@
package confirm
import (
- "context"
"fmt"
"os"
- "github.com/charmbracelet/bubbles/help"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/internal/exit"
- "github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/style"
+ "github.com/charmbracelet/huh"
)
// 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 {
- line, err := stdin.Read(stdin.SingleLine(true))
- if err == nil {
- switch line {
- case "yes", "y":
- return nil
- default:
- return exit.ErrExit(1)
- }
+ 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),
+ ),
+ ).
+ WithTheme(theme).
+ WithShowHelp(o.ShowHelp).
+ Run()
+
+ if err != nil {
+ return fmt.Errorf("unable to run confirm: %w", err)
}
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- top, right, bottom, left := style.ParsePadding(o.Padding)
- m := model{
- affirmative: o.Affirmative,
- negative: o.Negative,
- showOutput: o.ShowOutput,
- confirmation: o.Default,
- defaultSelection: o.Default,
- 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(),
- padding: []int{top, right, bottom, left},
- }
- tm, err := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithContext(ctx),
- ).Run()
- if err != nil && ctx.Err() != context.DeadlineExceeded {
- return fmt.Errorf("unable to confirm: %w", err)
- }
- m = tm.(model)
-
- if o.ShowOutput {
- confirmationText := m.negative
- if m.confirmation {
- confirmationText = m.affirmative
- }
- fmt.Println(m.prompt, confirmationText)
+ if !choice {
+ os.Exit(1)
}
- if m.confirmation {
- return nil
- }
-
- return exit.ErrExit(1)
+ return nil
}
diff --git a/confirm/confirm.go b/confirm/confirm.go
deleted file mode 100644
index ac35c39..0000000
--- a/confirm/confirm.go
+++ /dev/null
@@ -1,168 +0,0 @@
-// 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 (
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/key"
-
- 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
- showHelp bool
- help help.Model
- keys keymap
-
- showOutput bool
- confirmation bool
-
- defaultSelection bool
-
- // styles
- promptStyle lipgloss.Style
- selectedStyle lipgloss.Style
- unselectedStyle lipgloss.Style
- padding []int
-}
-
-func (m model) Init() tea.Cmd { return nil }
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- return m, nil
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, m.keys.Abort):
- m.confirmation = false
- return m, tea.Interrupt
- 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
- }
- }
- return m, nil
-}
-
-func (m model) View() string {
- if m.quitting {
- return ""
- }
-
- var aff, neg string
-
- if m.confirmation {
- aff = m.selectedStyle.Render(m.affirmative)
- neg = m.unselectedStyle.Render(m.negative)
- } else {
- aff = m.unselectedStyle.Render(m.affirmative)
- neg = m.selectedStyle.Render(m.negative)
- }
-
- // If the option is intentionally empty, do not show it.
- if m.negative == "" {
- neg = ""
- }
-
- parts := []string{
- m.promptStyle.Render(m.prompt) + "\n",
- lipgloss.JoinHorizontal(lipgloss.Left, aff, neg),
- }
-
- if m.showHelp {
- parts = append(parts, "", m.help.View(m.keys))
- }
-
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- ))
-}
diff --git a/confirm/options.go b/confirm/options.go
index 9740885..f604816 100644
--- a/confirm/options.go
+++ b/confirm/options.go
@@ -9,7 +9,6 @@ import (
// Options is the customization options for the confirm command.
type Options struct {
Default bool `help:"Default confirmation action" default:"true"`
- ShowOutput bool `help:"Print prompt and chosen action to output" default:"false"`
Affirmative string `help:"The title of the affirmative action" default:"Yes"`
Negative string `help:"The title of the negative action" default:"No"`
Prompt string `arg:"" help:"Prompt to display." default:"Are you sure?"`
@@ -20,6 +19,5 @@ type Options struct {
//nolint:staticcheck
UnselectedStyle style.Styles `embed:"" prefix:"unselected." help:"The style of the unselected action" set:"defaultBackground=235" set:"defaultForeground=254" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_UNSELECTED_"`
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_CONFIRM_SHOW_HELP"`
- Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0s" env:"GUM_CONFIRM_TIMEOUT"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_CONFIRM_PADDING"`
+ Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0" env:"GUM_CONFIRM_TIMEOUT"`
}
diff --git a/cursor/cursor.go b/cursor/cursor.go
index 3c49849..aa49c05 100644
--- a/cursor/cursor.go
+++ b/cursor/cursor.go
@@ -1,4 +1,3 @@
-// Package cursor provides cursor modes.
package cursor
import (
diff --git a/default.nix b/default.nix
index 511568f..b0cdedf 100644
--- a/default.nix
+++ b/default.nix
@@ -2,11 +2,11 @@
pkgs.buildGoModule rec {
pname = "gum";
- version = "0.15.2";
+ version = "0.14.0";
src = ./.;
- vendorHash = "sha256-TK2Fc4bTkiSpyYrg4dJOzamEnii03P7kyHZdah9izqY=";
+ vendorHash = "sha256-gDDaKrwlrJyyDzgyGf9iP/XPnOAwpkvIyzCXobXrlF4=";
ldflags = [ "-s" "-w" "-X=main.Version=${version}" ];
}
diff --git a/file/command.go b/file/command.go
index b7cc546..f60ed29 100644
--- a/file/command.go
+++ b/file/command.go
@@ -3,14 +3,10 @@ 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/timeout"
- "github.com/charmbracelet/gum/style"
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
)
// Run is the interface to picking a file.
@@ -28,52 +24,40 @@ func (o Options) Run() error {
return fmt.Errorf("file not found: %w", err)
}
- fp := filepicker.New()
- fp.CurrentDirectory = path
- fp.Path = path
- fp.SetHeight(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()
- top, right, bottom, left := style.ParsePadding(o.Padding)
- m := model{
- filepicker: fp,
- padding: []int{top, right, bottom, left},
- showHelp: o.ShowHelp,
- help: help.New(),
- keymap: defaultKeymap(),
- headerStyle: o.HeaderStyle.ToLipgloss(),
- header: o.Header,
- }
+ 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()
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
+ 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).
+ DirAllowed(o.Directory).
+ FileAllowed(o.File).
+ Height(o.Height).
+ ShowHidden(o.All).
+ Value(&path),
+ ),
+ ).
+ WithShowHelp(o.ShowHelp).
+ WithKeyMap(keymap).
+ WithTheme(theme).
+ Run()
- tm, err := tea.NewProgram(
- &m,
- tea.WithOutput(os.Stderr),
- tea.WithContext(ctx),
- ).Run()
if err != nil {
- return fmt.Errorf("unable to pick selection: %w", err)
- }
- m = tm.(model)
- if m.selectedPath == "" {
- return errors.New("no file selected")
+ return err
}
- fmt.Println(m.selectedPath)
+ fmt.Println(path)
return nil
}
diff --git a/file/file.go b/file/file.go
deleted file mode 100644
index 33c8233..0000000
--- a/file/file.go
+++ /dev/null
@@ -1,119 +0,0 @@
-// 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 (
- "github.com/charmbracelet/bubbles/filepicker"
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "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()
- 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{
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("↓↑", "navigate"),
- ),
- keyQuit,
- k.Select,
- }
-}
-
-type model struct {
- header string
- headerStyle lipgloss.Style
- filepicker filepicker.Model
- selectedPath string
- quitting bool
- showHelp bool
- padding []int
- help help.Model
- keymap keymap
-}
-
-func (m model) Init() tea.Cmd { return m.filepicker.Init() }
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- height := msg.Height - m.padding[0] - m.padding[2]
- if m.showHelp {
- height -= lipgloss.Height(m.helpView())
- }
- m.filepicker.SetHeight(height)
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, keyAbort):
- m.quitting = true
- return m, tea.Interrupt
- case key.Matches(msg, keyQuit):
- m.quitting = true
- return m, tea.Quit
- }
- }
- 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 ""
- }
- var parts []string
- if m.header != "" {
- parts = append(parts, m.headerStyle.Render(m.header))
- }
- parts = append(parts, m.filepicker.View())
- if m.showHelp {
- parts = append(parts, m.helpView())
- }
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- ))
-}
-
-func (m model) helpView() string {
- return m.help.View(m.keymap)
-}
diff --git a/file/options.go b/file/options.go
index 61c9ced..4b8e88d 100644
--- a/file/options.go
+++ b/file/options.go
@@ -11,24 +11,21 @@ type Options struct {
// Path is the path to the folder / directory to begin traversing.
Path string `arg:"" optional:"" name:"path" help:"The path to the folder to begin traversing" env:"GUM_FILE_PATH"`
// Cursor is the character to display in front of the current selected items.
- Cursor string `short:"c" help:"The cursor character" default:">" env:"GUM_FILE_CURSOR"`
- All bool `short:"a" help:"Show hidden and 'dot' files" default:"false" env:"GUM_FILE_ALL"`
- Permissions bool `short:"p" help:"Show file permissions" default:"true" negatable:"" env:"GUM_FILE_PERMISSION"`
- Size bool `short:"s" help:"Show file size" default:"true" negatable:"" env:"GUM_FILE_SIZE"`
- File bool `help:"Allow files selection" default:"true" env:"GUM_FILE_FILE"`
- 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"`
- Timeout time.Duration `help:"Timeout until command aborts without a selection" default:"0s" env:"GUM_FILE_TIMEOUT"`
- Header string `help:"Header value" default:"" env:"GUM_FILE_HEADER"`
- Height int `help:"Maximum number of files to display" default:"10" env:"GUM_FILE_HEIGHT"`
+ Cursor string `short:"c" help:"The cursor character" default:">" env:"GUM_FILE_CURSOR"`
+ All bool `short:"a" help:"Show hidden and 'dot' files" default:"false" env:"GUM_FILE_ALL"`
+ File bool `help:"Allow files selection" default:"true" env:"GUM_FILE_FILE"`
+ 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:"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_"`
FileStyle style.Styles `embed:"" prefix:"file." help:"The style to use for files" envprefix:"GUM_FILE_FILE_"`
PermissionsStyle style.Styles `embed:"" prefix:"permissions." help:"The style to use for permissions" set:"defaultForeground=244" envprefix:"GUM_FILE_PERMISSIONS_"`
- SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style to use for the selected item" set:"defaultBold=true" set:"defaultForeground=212" envprefix:"GUM_FILE_SELECTED_"` //nolint:staticcheck
- FileSizeStyle style.Styles `embed:"" prefix:"file-size." help:"The style to use for file sizes" set:"defaultWidth=8" set:"defaultAlign=right" set:"defaultForeground=240" envprefix:"GUM_FILE_FILE_SIZE_"` //nolint:staticcheck
- HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILE_HEADER_"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_FILE_PADDING"`
+ //nolint:staticcheck
+ SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style to use for the selected item" set:"defaultBold=true" set:"defaultForeground=212" envprefix:"GUM_FILE_SELECTED_"`
+ //nolint:staticcheck
+ FileSizeStyle style.Styles `embed:"" prefix:"file-size." help:"The style to use for file sizes" set:"defaultWidth=8" set:"defaultAlign=right" set:"defaultForeground=240" envprefix:"GUM_FILE_FILE_SIZE_"`
+ Timeout time.Duration `help:"Timeout until command aborts without a selection" default:"0" env:"GUM_FILE_TIMEOUT"`
}
diff --git a/filter/command.go b/filter/command.go
index 6c9b2a3..6ae7b28 100644
--- a/filter/command.go
+++ b/filter/command.go
@@ -4,20 +4,18 @@ import (
"errors"
"fmt"
"os"
- "slices"
"strings"
- "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/term"
+ "github.com/sahilm/fuzzy"
+
+ "github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/internal/files"
"github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/internal/tty"
- "github.com/charmbracelet/gum/style"
- "github.com/charmbracelet/x/ansi"
- "github.com/sahilm/fuzzy"
)
// Run provides a shell script interface for filtering through options, powered
@@ -35,8 +33,8 @@ func (o Options) Run() error {
v := viewport.New(o.Width, o.Height)
if len(o.Options) == 0 {
- if input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); input != "" {
- o.Options = strings.Split(input, o.InputDelimiter)
+ if input, _ := stdin.Read(); input != "" {
+ o.Options = strings.Split(input, "\n")
} else {
o.Options = files.List()
}
@@ -46,14 +44,12 @@ func (o Options) Run() error {
return errors.New("no options provided, see `gum filter --help`")
}
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- options := []tea.ProgramOption{
- tea.WithOutput(os.Stderr),
- tea.WithReportFocus(),
- tea.WithContext(ctx),
+ if o.SelectIfOne && len(o.Options) == 1 {
+ fmt.Println(o.Options[0])
+ return nil
}
+
+ options := []tea.ProgramOption{tea.WithOutput(os.Stderr)}
if o.Height == 0 {
options = append(options, tea.WithAltScreen())
}
@@ -62,43 +58,21 @@ func (o Options) Run() error {
if o.Value != "" {
i.SetValue(o.Value)
}
-
- choices := map[string]string{}
- filteringChoices := []string{}
- for _, opt := range o.Options {
- s := ansi.Strip(opt)
- choices[s] = opt
- filteringChoices = append(filteringChoices, s)
- }
switch {
case o.Value != "" && o.Fuzzy:
- matches = fuzzy.Find(o.Value, filteringChoices)
+ matches = fuzzy.Find(o.Value, o.Options)
case o.Value != "" && !o.Fuzzy:
- matches = exactMatches(o.Value, filteringChoices)
+ matches = exactMatches(o.Value, o.Options)
default:
- matches = matchAll(filteringChoices)
+ matches = matchAll(o.Options)
}
if o.NoLimit {
o.Limit = len(o.Options)
}
- if o.SelectIfOne && len(matches) == 1 {
- tty.Println(matches[0].Str)
- return nil
- }
-
- km := defaultKeymap()
- if o.NoLimit || o.Limit > 1 {
- km.Toggle.SetEnabled(true)
- km.ToggleAndPrevious.SetEnabled(true)
- km.ToggleAndNext.SetEnabled(true)
- km.ToggleAll.SetEnabled(true)
- }
- top, right, bottom, left := style.ParsePadding(o.Padding)
- m := model{
- choices: choices,
- filteringChoices: filteringChoices,
+ p := tea.NewProgram(model{
+ choices: o.Options,
indicator: o.Indicator,
matches: matches,
header: o.Header,
@@ -114,61 +88,51 @@ func (o Options) Run() error {
textStyle: o.TextStyle.ToLipgloss(),
cursorTextStyle: o.CursorTextStyle.ToLipgloss(),
height: o.Height,
- padding: []int{top, right, bottom, left},
selected: make(map[string]struct{}),
limit: o.Limit,
reverse: o.Reverse,
fuzzy: o.Fuzzy,
- sort: o.Sort && o.FuzzySort,
- strict: o.Strict,
- showHelp: o.ShowHelp,
- keymap: km,
- help: help.New(),
- }
+ timeout: o.Timeout,
+ hasTimeout: o.Timeout > 0,
+ sort: o.Sort,
+ }, options...)
- isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*"
- currentSelected := 0
- if len(o.Selected) > 0 {
- for i, option := range matches {
- if currentSelected >= o.Limit || (!isSelectAll && !slices.Contains(o.Selected, option.Str)) {
- continue
- }
- if o.Limit == 1 {
- m.cursor = i
- m.selected[option.Str] = struct{}{}
- } else {
- currentSelected++
- m.selected[option.Str] = struct{}{}
- }
- }
- }
-
- tm, err := tea.NewProgram(m, options...).Run()
+ tm, err := p.Run()
if err != nil {
return fmt.Errorf("unable to run filter: %w", err)
}
-
- m = tm.(model)
- if !m.submitted {
- return errors.New("nothing selected")
+ m := tm.(model)
+ if m.aborted {
+ return exit.ErrAborted
}
+ isTTY := term.IsTerminal(os.Stdout.Fd())
+
// allSelections contains values only if limit is greater
// than 1 or if flag --no-limit is passed, hence there is
// no need to further checks
if len(m.selected) > 0 {
- o.checkSelected(m)
+ o.checkSelected(m, isTTY)
} else if len(m.matches) > m.cursor && m.cursor >= 0 {
- tty.Println(m.matches[m.cursor].Str)
+ if isTTY {
+ fmt.Println(m.matches[m.cursor].Str)
+ } else {
+ fmt.Println(ansi.Strip(m.matches[m.cursor].Str))
+ }
}
+ if !o.Strict && len(m.textinput.Value()) != 0 && len(m.matches) == 0 {
+ fmt.Println(m.textinput.Value())
+ }
return nil
}
-func (o Options) checkSelected(m model) {
- out := []string{}
+func (o Options) checkSelected(m model, isTTY bool) {
for k := range m.selected {
- out = append(out, k)
+ if isTTY {
+ fmt.Println(k)
+ } else {
+ fmt.Println(ansi.Strip(k))
+ }
}
- tty.Println(strings.Join(out, o.OutputDelimiter))
}
diff --git a/filter/filter.go b/filter/filter.go
index 5e433cd..38c25f9 100644
--- a/filter/filter.go
+++ b/filter/filter.go
@@ -12,122 +12,21 @@ package filter
import (
"strings"
+ "time"
+
+ "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"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/exp/ordered"
- "github.com/rivo/uniseg"
"github.com/sahilm/fuzzy"
)
-func defaultKeymap() keymap {
- return keymap{
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+j", "ctrl+n"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+k", "ctrl+p"),
- ),
- NDown: key.NewBinding(
- key.WithKeys("j"),
- ),
- NUp: key.NewBinding(
- key.WithKeys("k"),
- ),
- Home: key.NewBinding(
- key.WithKeys("g", "home"),
- ),
- End: key.NewBinding(
- key.WithKeys("G", "end"),
- ),
- 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(),
- ),
- ToggleAll: key.NewBinding(
- key.WithKeys("ctrl+a"),
- key.WithHelp("ctrl+a", "select all"),
- key.WithDisabled(),
- ),
- FocusInSearch: key.NewBinding(
- key.WithKeys("/"),
- key.WithHelp("/", "search"),
- ),
- FocusOutSearch: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "blur search"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "quit"),
- ),
- Abort: key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "abort"),
- ),
- Submit: key.NewBinding(
- key.WithKeys("enter", "ctrl+q"),
- key.WithHelp("enter", "submit"),
- ),
- }
-}
-
-type keymap struct {
- FocusInSearch,
- FocusOutSearch,
- Down,
- Up,
- NDown,
- NUp,
- Home,
- End,
- ToggleAndNext,
- ToggleAndPrevious,
- ToggleAll,
- Toggle,
- Abort,
- Quit,
- 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{
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("↓↑", "navigate"),
- ),
- k.FocusInSearch,
- k.FocusOutSearch,
- k.ToggleAndNext,
- k.ToggleAll,
- k.Submit,
- }
-}
-
type model struct {
textinput textinput.Model
viewport *viewport.Model
- choices map[string]string
- filteringChoices []string
+ choices []string
matches []fuzzy.Match
cursor int
header string
@@ -138,7 +37,7 @@ type model struct {
selectedPrefix string
unselectedPrefix string
height int
- padding []int
+ aborted bool
quitting bool
headerStyle lipgloss.Style
matchStyle lipgloss.Style
@@ -150,15 +49,13 @@ type model struct {
reverse bool
fuzzy bool
sort bool
- showHelp bool
- keymap keymap
- help help.Model
- strict bool
- submitted bool
+ timeout time.Duration
+ hasTimeout bool
}
-func (m model) Init() tea.Cmd { return textinput.Blink }
-
+func (m model) Init() tea.Cmd {
+ return timeout.Init(m.timeout, nil)
+}
func (m model) View() string {
if m.quitting {
return ""
@@ -205,24 +102,30 @@ func (m model) View() string {
s.WriteString(" ")
}
- styledOption := m.choices[match.Str]
- if len(match.MatchedIndexes) == 0 {
- // No matches, just render the text.
- s.WriteString(lineTextStyle.Render(styledOption))
- s.WriteRune('\n')
- continue
- }
+ // For this match, there are a certain number of characters that have
+ // caused the match. i.e. fuzzy matching.
+ // We should indicate to the users which characters are being matched.
+ mi := 0
+ var buf strings.Builder
+ for ci, c := range match.Str {
+ // Check if the current character index matches the current matched
+ // index. If so, color the character to indicate a match.
+ if mi < len(match.MatchedIndexes) && ci == match.MatchedIndexes[mi] {
+ // Flush text buffer.
+ s.WriteString(lineTextStyle.Render(buf.String()))
+ buf.Reset()
- var ranges []lipgloss.Range
- for _, rng := range matchedRanges(match.MatchedIndexes) {
- // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
- // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
- // so we need to adjust it here:
- start, stop := bytePosToVisibleCharPos(match.Str, rng)
- ranges = append(ranges, lipgloss.NewRange(start, stop+1, m.matchStyle))
+ s.WriteString(m.matchStyle.Render(string(c)))
+ // We have matched this character, so we never have to check it
+ // again. Move on to the next match.
+ mi++
+ } else {
+ // Not a match, buffer a regular character.
+ buf.WriteRune(c)
+ }
}
-
- s.WriteString(lineTextStyle.Render(lipgloss.StyleRanges(styledOption, ranges...)))
+ // Flush text buffer.
+ s.WriteString(lineTextStyle.Render(buf.String()))
// We have finished displaying the match with all of it's matched
// characters highlighted and the rest filled in.
@@ -235,114 +138,79 @@ func (m model) View() string {
// View the input and the filtered choices
header := m.headerStyle.Render(m.header)
if m.reverse {
- view := m.viewport.View()
+ view := m.viewport.View() + "\n" + m.textinput.View()
if m.header != "" {
- view += "\n" + header
+ return lipgloss.JoinVertical(lipgloss.Left, view, header)
}
- view += "\n" + m.textinput.View()
- if m.showHelp {
- view += m.helpView()
- }
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(view)
+
+ return view
}
view := m.textinput.View() + "\n" + m.viewport.View()
- if m.showHelp {
- view += m.helpView()
- }
if m.header != "" {
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(header + "\n" + view)
+ return lipgloss.JoinVertical(lipgloss.Left, header, view)
}
-
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(view)
-}
-
-func (m model) helpView() string {
- return "\n\n" + m.help.View(m.keymap)
+ return view
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd, icmd tea.Cmd
- m.textinput, icmd = m.textinput.Update(msg)
+ var cmd tea.Cmd
switch msg := msg.(type) {
+ case timeout.TickTimeoutMsg:
+ if msg.TimeoutValue <= 0 {
+ m.quitting = true
+ m.aborted = true
+ return m, tea.Quit
+ }
+ m.timeout = msg.TimeoutValue
+ return m, timeout.Tick(msg.TimeoutValue, msg.Data)
+
case tea.WindowSizeMsg:
if m.height == 0 || m.height > msg.Height {
m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View())
}
- // Include the header in the height calculation.
+
+ // Make place in the view port if header is set
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.Height = m.viewport.Height - m.padding[0] - m.padding[2]
- m.viewport.Width = msg.Width - m.padding[1] - m.padding[3]
- m.textinput.Width = msg.Width - m.padding[1] - m.padding[3]
+ m.viewport.Width = msg.Width
if m.reverse {
- m.viewport.YOffset = ordered.Clamp(len(m.matches)-m.viewport.Height, 0, len(m.matches))
+ m.viewport.YOffset = clamp(0, len(m.matches), len(m.matches)-m.viewport.Height)
}
case tea.KeyMsg:
- km := m.keymap
- switch {
- case key.Matches(msg, km.FocusInSearch):
- m.textinput.Focus()
- case key.Matches(msg, km.FocusOutSearch):
- m.textinput.Blur()
- case key.Matches(msg, km.Quit):
+ switch msg.String() {
+ case "ctrl+c", "esc":
+ m.aborted = true
m.quitting = true
return m, tea.Quit
- case key.Matches(msg, km.Abort):
+ case "enter":
m.quitting = true
- return m, tea.Interrupt
- case key.Matches(msg, km.Submit):
- m.quitting = true
- m.submitted = true
return m, tea.Quit
- case key.Matches(msg, km.Down, km.NDown):
+ case "ctrl+n", "ctrl+j", "down":
m.CursorDown()
- case key.Matches(msg, km.Up, km.NUp):
+ case "ctrl+p", "ctrl+k", "up":
m.CursorUp()
- case key.Matches(msg, km.Home):
- m.cursor = 0
- m.viewport.GotoTop()
- case key.Matches(msg, km.End):
- m.cursor = len(m.choices) - 1
- m.viewport.GotoBottom()
- case key.Matches(msg, km.ToggleAndNext):
+ case "tab":
if m.limit == 1 {
break // no op
}
m.ToggleSelection()
m.CursorDown()
- case key.Matches(msg, km.ToggleAndPrevious):
+ case "shift+tab":
if m.limit == 1 {
break // no op
}
m.ToggleSelection()
m.CursorUp()
- case key.Matches(msg, km.Toggle):
+ case "ctrl+@":
if m.limit == 1 {
break // no op
}
m.ToggleSelection()
- case key.Matches(msg, km.ToggleAll):
- if m.limit <= 1 {
- break
- }
- if m.numSelected < len(m.matches) && m.numSelected < m.limit {
- m = m.selectAll()
- } else {
- m = m.deselectAll()
- }
default:
+ m.textinput, cmd = m.textinput.Update(msg)
+
// yOffsetFromBottom is the number of lines from the bottom of the
// list to the top of the viewport. This is used to keep the viewport
// at a constant position when the number of matches are reduced
@@ -354,91 +222,61 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// A character was entered, this likely means that the text input has
// changed. This suggests that the matches are outdated, so update them.
- var choices []string
- if !m.strict {
- choices = append(choices, m.textinput.Value())
- }
- choices = append(choices, m.filteringChoices...)
if m.fuzzy {
if m.sort {
- m.matches = fuzzy.Find(m.textinput.Value(), choices)
+ m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
} else {
- m.matches = fuzzy.FindNoSort(m.textinput.Value(), choices)
+ m.matches = fuzzy.FindNoSort(m.textinput.Value(), m.choices)
}
} else {
- m.matches = exactMatches(m.textinput.Value(), choices)
+ m.matches = exactMatches(m.textinput.Value(), m.choices)
}
// If the search field is empty, let's not display the matches
// (none), but rather display all possible choices.
if m.textinput.Value() == "" {
- m.matches = matchAll(m.filteringChoices)
+ m.matches = matchAll(m.choices)
}
// For reverse layout, we need to offset the viewport so that the
// it remains at a constant position relative to the cursor.
if m.reverse {
maxYOffset := max(0, len(m.matches)-m.viewport.Height)
- m.viewport.YOffset = ordered.Clamp(len(m.matches)-yOffsetFromBottom, 0, maxYOffset)
+ m.viewport.YOffset = clamp(0, maxYOffset, len(m.matches)-yOffsetFromBottom)
}
}
}
- m.keymap.FocusInSearch.SetEnabled(!m.textinput.Focused())
- m.keymap.FocusOutSearch.SetEnabled(m.textinput.Focused())
- m.keymap.NUp.SetEnabled(!m.textinput.Focused())
- m.keymap.NDown.SetEnabled(!m.textinput.Focused())
- m.keymap.Home.SetEnabled(!m.textinput.Focused())
- m.keymap.End.SetEnabled(!m.textinput.Focused())
-
// It's possible that filtering items have caused fewer matches. So, ensure
// that the selected index is within the bounds of the number of matches.
- m.cursor = ordered.Clamp(m.cursor, 0, len(m.matches)-1)
- return m, tea.Batch(cmd, icmd)
+ m.cursor = clamp(0, len(m.matches)-1, m.cursor)
+ return m, cmd
}
func (m *model) CursorUp() {
- if len(m.matches) == 0 {
- return
- }
- if m.reverse { //nolint:nestif
- m.cursor = (m.cursor + 1) % len(m.matches)
+ if m.reverse {
+ m.cursor = clamp(0, len(m.matches)-1, m.cursor+1)
if len(m.matches)-m.cursor <= m.viewport.YOffset {
- m.viewport.ScrollUp(1)
- }
- if len(m.matches)-m.cursor > m.viewport.Height+m.viewport.YOffset {
- m.viewport.SetYOffset(len(m.matches) - m.viewport.Height)
+ m.viewport.SetYOffset(len(m.matches) - m.cursor - 1)
}
} else {
- m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches)
+ m.cursor = clamp(0, len(m.matches)-1, m.cursor-1)
if m.cursor < m.viewport.YOffset {
- m.viewport.ScrollUp(1)
- }
- if m.cursor >= m.viewport.YOffset+m.viewport.Height {
- m.viewport.SetYOffset(len(m.matches) - m.viewport.Height)
+ m.viewport.SetYOffset(m.cursor)
}
}
}
func (m *model) CursorDown() {
- if len(m.matches) == 0 {
- return
- }
- if m.reverse { //nolint:nestif
- m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches)
+ if m.reverse {
+ m.cursor = clamp(0, len(m.matches)-1, m.cursor-1)
if len(m.matches)-m.cursor > m.viewport.Height+m.viewport.YOffset {
- m.viewport.ScrollDown(1)
- }
- if len(m.matches)-m.cursor <= m.viewport.YOffset {
- m.viewport.GotoTop()
+ m.viewport.LineDown(1)
}
} else {
- m.cursor = (m.cursor + 1) % len(m.matches)
+ m.cursor = clamp(0, len(m.matches)-1, m.cursor+1)
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
- m.viewport.ScrollDown(1)
- }
- if m.cursor < m.viewport.YOffset {
- m.viewport.GotoTop()
+ m.viewport.LineDown(1)
}
}
}
@@ -453,26 +291,6 @@ func (m *model) ToggleSelection() {
}
}
-func (m model) selectAll() model {
- for i := range m.matches {
- if m.numSelected >= m.limit {
- break // do not exceed given limit
- }
- if _, ok := m.selected[m.matches[i].Str]; ok {
- continue
- }
- m.selected[m.matches[i].Str] = struct{}{}
- m.numSelected++
- }
- return m
-}
-
-func (m model) deselectAll() model {
- m.selected = make(map[string]struct{})
- m.numSelected = 0
- return m
-}
-
func matchAll(options []string) []fuzzy.Match {
matches := make([]fuzzy.Match, len(options))
for i, option := range options {
@@ -504,46 +322,20 @@ func exactMatches(search string, choices []string) []fuzzy.Match {
return matches
}
-func matchedRanges(in []int) [][2]int {
- if len(in) == 0 {
- return [][2]int{}
+//nolint:unparam
+func clamp(min, max, val int) int {
+ if val < min {
+ return min
}
- current := [2]int{in[0], in[0]}
- if len(in) == 1 {
- return [][2]int{current}
+ if val > max {
+ return max
}
- var out [][2]int
- for i := 1; i < len(in); i++ {
- if in[i] == current[1]+1 {
- current[1] = in[i]
- } else {
- out = append(out, current)
- current = [2]int{in[i], in[i]}
- }
- }
- out = append(out, current)
- return out
+ return val
}
-func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
- bytePos, byteStart, byteStop := 0, rng[0], rng[1]
- pos, start, stop := 0, 0, 0
- gr := uniseg.NewGraphemes(str)
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
+func max(a, b int) int {
+ if a > b {
+ return a
}
- start = pos
- for byteStop > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- stop = pos
- return start, stop
+ return b
}
diff --git a/filter/filter_test.go b/filter/filter_test.go
deleted file mode 100644
index 5840002..0000000
--- a/filter/filter_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package filter
-
-import (
- "reflect"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-func TestMatchedRanges(t *testing.T) {
- for name, tt := range map[string]struct {
- in []int
- out [][2]int
- }{
- "empty": {
- in: []int{},
- out: [][2]int{},
- },
- "one char": {
- in: []int{1},
- out: [][2]int{{1, 1}},
- },
- "2 char range": {
- in: []int{1, 2},
- out: [][2]int{{1, 2}},
- },
- "multiple char range": {
- in: []int{1, 2, 3, 4, 5, 6},
- out: [][2]int{{1, 6}},
- },
- "multiple char ranges": {
- in: []int{1, 2, 3, 5, 6, 10, 11, 12, 13, 23, 24, 40, 42, 43, 45, 52},
- out: [][2]int{{1, 3}, {5, 6}, {10, 13}, {23, 24}, {40, 40}, {42, 43}, {45, 45}, {52, 52}},
- },
- } {
- t.Run(name, func(t *testing.T) {
- match := matchedRanges(tt.in)
- if !reflect.DeepEqual(match, tt.out) {
- t.Errorf("expected %v, got %v", tt.out, match)
- }
- })
- }
-}
-
-func TestByteToChar(t *testing.T) {
- stStr := "\x1b[90m\ue615\x1b[39m \x1b[3m\x1b[32mDow\x1b[0m\x1b[90m\x1b[39m\x1b[3wnloads"
- str := " Downloads"
- rng := [2]int{4, 7}
- expect := "Dow"
-
- if got := str[rng[0]:rng[1]]; got != expect {
- t.Errorf("expected %q, got %q", expect, got)
- }
-
- start, stop := bytePosToVisibleCharPos(str, rng)
- if got := ansi.Strip(ansi.Cut(stStr, start, stop)); got != expect {
- t.Errorf("expected %+q, got %+q", expect, got)
- }
-}
diff --git a/filter/options.go b/filter/options.go
index 26eb3ea..489c734 100644
--- a/filter/options.go
+++ b/filter/options.go
@@ -15,14 +15,12 @@ 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"`
- Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_FILTER_SELECTED"`
- 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"`
+ Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" 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=99" envprefix:"GUM_FILTER_HEADER_"`
+ HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" 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_"`
@@ -31,18 +29,11 @@ type Options struct {
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"`
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"`
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_FILTER_PLACEHOLDER_"`
- Width int `help:"Input width" default:"0" env:"GUM_FILTER_WIDTH"`
+ Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"`
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"`
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"`
Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"`
- Fuzzy bool `help:"Enable fuzzy matching; otherwise match from start of word" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
- FuzzySort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:""`
- Timeout time.Duration `help:"Timeout until filter command aborts" default:"0s" env:"GUM_FILTER_TIMEOUT"`
- InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_FILTER_INPUT_DELIMITER"`
- OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_FILTER_OUTPUT_DELIMITER"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FILTER_STRIP_ANSI"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_FILTER_PADDING"`
-
- // Deprecated: use [FuzzySort]. This will be removed at some point.
- Sort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:"" hidden:""`
+ Fuzzy bool `help:"Enable fuzzy matching" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
+ Sort bool `help:"Sort the results" default:"true" env:"GUM_FILTER_SORT" negatable:""`
+ Timeout time.Duration `help:"Timeout until filter command aborts" default:"0" env:"GUM_FILTER_TIMEOUT"`
}
diff --git a/flake.lock b/flake.lock
index 2537edd..7ceb95c 100644
--- a/flake.lock
+++ b/flake.lock
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
- "lastModified": 1731533236,
- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "lastModified": 1710146030,
+ "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1737062831,
- "narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=",
+ "lastModified": 1715447595,
+ "narHash": "sha256-VsVAUQOj/cS1LCOmMjAGeRksXIAdPnFIjCQ0XLkCsT0=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c",
+ "rev": "062ca2a9370a27a35c524dc82d540e6e9824b652",
"type": "github"
},
"original": {
diff --git a/format/README.md b/format/README.md
index 3bf68d0..288f234 100644
--- a/format/README.md
+++ b/format/README.md
@@ -7,7 +7,7 @@ Four different parse-able formats exist:
1. [Markdown](#markdown)
2. [Code](#code)
3. [Template](#template)
-4. [Emoji](#emoji)
+3. [Emoji](#emoji)
## Markdown
@@ -43,7 +43,7 @@ Render styled input from a string template. Templating is handled by
```bash
gum format --type template '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}'
# Or, via stdin
-echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' | gum format --type template
+echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' | gum format --type template
```
## Emoji
@@ -55,30 +55,5 @@ Emoji](https://github.com/yuin/goldmark-emoji)
```bash
gum format --type emoji 'I :heart: Bubble Gum :candy:'
# You know the drill, also via stdin
-echo 'I :heart: Bubble Gum :candy:' | gum format --type emoji
+echo 'I :heart: Bubble Gum :candy:' | gum format --type emoji
```
-
-## Tables
-
-Tables are rendered using [Glamour](https://github.com/charmbracelet/glamour).
-
-| Bubble Gum Flavor | Price |
-| ----------------- | ----- |
-| Strawberry | $0.99 |
-| Cherry | $0.50 |
-| Banana | $0.75 |
-| Orange | $0.25 |
-| Lemon | $0.50 |
-| Lime | $0.50 |
-| Grape | $0.50 |
-| Watermelon | $0.50 |
-| Pineapple | $0.50 |
-| Blueberry | $0.50 |
-| Raspberry | $0.50 |
-| Cranberry | $0.50 |
-| Peach | $0.50 |
-| Apple | $0.50 |
-| Mango | $0.50 |
-| Pomegranate | $0.50 |
-| Coconut | $0.50 |
-| Cinnamon | $0.50 |
diff --git a/format/command.go b/format/command.go
index 3217f0e..82ebf0c 100644
--- a/format/command.go
+++ b/format/command.go
@@ -24,7 +24,7 @@ func (o Options) Run() error {
if len(o.Template) > 0 {
input = strings.Join(o.Template, "\n")
} else {
- input, _ = stdin.Read(stdin.StripANSI(o.StripANSI))
+ input, _ = stdin.Read()
}
switch o.Type {
diff --git a/format/options.go b/format/options.go
index ee87f95..6f36dcd 100644
--- a/format/options.go
+++ b/format/options.go
@@ -6,7 +6,5 @@ type Options struct {
Theme string `help:"Glamour theme to use for markdown formatting" default:"pink" env:"GUM_FORMAT_THEME"`
Language string `help:"Programming language to parse code" short:"l" default:"" env:"GUM_FORMAT_LANGUAGE"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FORMAT_STRIP_ANSI"`
-
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"`
}
diff --git a/go.mod b/go.mod
index 55e2352..f720d71 100644
--- a/go.mod
+++ b/go.mod
@@ -1,26 +1,22 @@
module github.com/charmbracelet/gum
-go 1.24.2
+go 1.21
require (
- github.com/Masterminds/semver/v3 v3.4.0
- github.com/alecthomas/kong v1.14.0
+ github.com/alecthomas/kong v0.9.0
github.com/alecthomas/mango-kong v0.1.0
- github.com/charmbracelet/bubbles v1.0.0
- github.com/charmbracelet/bubbletea v1.3.10
- github.com/charmbracelet/glamour v0.10.0
- github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
- github.com/charmbracelet/log v0.4.2
- github.com/charmbracelet/x/ansi v0.11.6
- github.com/charmbracelet/x/editor v0.2.0
- github.com/charmbracelet/x/exp/ordered v0.1.0
- github.com/charmbracelet/x/term v0.2.2
- github.com/charmbracelet/x/xpty v0.1.3
+ github.com/charmbracelet/bubbles v0.18.0
+ github.com/charmbracelet/bubbletea v0.26.7-0.20240716165615-7d708384a105
+ github.com/charmbracelet/glamour v0.7.0
+ github.com/charmbracelet/huh v0.5.2
+ github.com/charmbracelet/lipgloss v0.12.1
+ github.com/charmbracelet/log v0.4.0
+ github.com/charmbracelet/x/ansi v0.1.4
+ github.com/charmbracelet/x/term v0.1.1
+ github.com/muesli/reflow v0.3.0
github.com/muesli/roff v0.1.0
- github.com/muesli/termenv v0.16.0
- github.com/rivo/uniseg v0.4.7
+ github.com/muesli/termenv v0.15.2
github.com/sahilm/fuzzy v0.1.1
- golang.org/x/text v0.34.0
)
require (
@@ -28,35 +24,32 @@ 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/charmbracelet/colorprofile v0.4.1 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
- github.com/charmbracelet/x/conpty v0.1.1 // indirect
- github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
- github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
- github.com/charmbracelet/x/termios v0.1.1 // indirect
- github.com/clipperhouse/displaywidth v0.9.0 // indirect
- github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
- github.com/creack/pty v1.1.24 // indirect
+ github.com/catppuccin/go v0.2.0 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+ github.com/charmbracelet/x/input v0.1.3 // indirect
+ github.com/charmbracelet/x/windows v0.1.2 // 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
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.19 // indirect
- github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.26 // 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
- github.com/muesli/reflow v0.3.0 // indirect
+ github.com/olekukonko/tablewriter v0.0.5 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- github.com/yuin/goldmark v1.7.8 // indirect
- github.com/yuin/goldmark-emoji v1.0.5 // indirect
+ github.com/yuin/goldmark v1.7.2 // indirect
+ github.com/yuin/goldmark-emoji v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
- golang.org/x/net v0.40.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/term v0.32.0 // indirect
+ golang.org/x/net v0.26.0 // indirect
+ golang.org/x/sync v0.7.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
)
diff --git a/go.sum b/go.sum
index c3d39b7..4c66da0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,67 +1,45 @@
-github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
-github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
-github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
-github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
-github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
-github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
+github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
-github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s=
-github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
+github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
+github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
github.com/alecthomas/mango-kong v0.1.0 h1:iFVfP1k1K4qpml3JUQmD5I8MCQYfIvsD9mRdrw7jJC4=
github.com/alecthomas/mango-kong v0.1.0/go.mod h1:t+TYVdsONUolf/BwVcm+15eqcdAj15h4Qe9MMFAwwT4=
-github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
-github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
-github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
-github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
-github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
-github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
-github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
-github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
-github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
-github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
-github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
-github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
-github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
-github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
-github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
-github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
-github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
-github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
-github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
-github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
-github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
-github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
-github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
-github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
-github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
-github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
-github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
-github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
-github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
-github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+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.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
+github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
+github.com/charmbracelet/bubbletea v0.26.7-0.20240716165615-7d708384a105 h1:ye4X1GMrzY6ebvZeUB9bgyxreb5xxa5o9Kd/Y/auxFs=
+github.com/charmbracelet/bubbletea v0.26.7-0.20240716165615-7d708384a105/go.mod h1:gw7FxN8J9u7IAlwc1ab1GnbfOMGExC9iI0e1t2SHs6I=
+github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng=
+github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps=
+github.com/charmbracelet/huh v0.5.2 h1:ofeNkJ4iaFnzv46Njhx896DzLUe/j0L2QAf8znwzX4c=
+github.com/charmbracelet/huh v0.5.2/go.mod h1:Sf7dY0oAn6N/e3sXJFtFX9hdQLrUdO3z7AYollG9bAM=
+github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
+github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
+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.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
+github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+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/exp/term v0.0.0-20240524151031-ff83003bf67a h1:k/s6UoOSVynWiw7PlclyGO2VdVs5ZLbMIHiGp4shFZE=
+github.com/charmbracelet/x/exp/term v0.0.0-20240524151031-ff83003bf67a/go.mod h1:YBotIGhfoWhHDlnUpJMkjebGV2pdGRCn1Y4/Nk/vVcU=
+github.com/charmbracelet/x/input v0.1.3 h1:oy4TMhyGQsYs/WWJwu1ELUMFnjiUAXwtDf048fHbCkg=
+github.com/charmbracelet/x/input v0.1.3/go.mod h1:1gaCOyw1KI9e2j00j/BBZ4ErzRZqa05w0Ghn83yIhKU=
+github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
+github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
+github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
+github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
@@ -78,17 +56,20 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
+github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
+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=
@@ -99,8 +80,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -109,26 +92,26 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
-github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
-github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc=
+github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
+github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
-golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
-golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
-golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/gum.go b/gum.go
index 4191314..2808c76 100644
--- a/gum.go
+++ b/gum.go
@@ -17,7 +17,6 @@ import (
"github.com/charmbracelet/gum/spin"
"github.com/charmbracelet/gum/style"
"github.com/charmbracelet/gum/table"
- "github.com/charmbracelet/gum/version"
"github.com/charmbracelet/gum/write"
)
@@ -134,7 +133,7 @@ type Gum struct {
// │ 7 │ │
// │ 8 │ │
// ╰────────────────────────────────────────────────╯
- // ↓↑: navigate • q: quit
+ // ↑/↓: Navigate • q: Quit
//
Pager pager.Options `cmd:"" help:"Scroll through a file"`
@@ -215,14 +214,4 @@ type Gum struct {
// $ gum log --level info "Hello, world!"
//
Log log.Options `cmd:"" help:"Log messages to output"`
-
- // VersionCheck provides a command that checks if the current gum version
- // matches a given semantic version constraint.
- //
- // It can be used to check that a minimum gum version is installed in a
- // script.
- //
- // $ gum version-check '~> 0.15'
- //
- VersionCheck version.Options `cmd:"" help:"Semver check current gum version"`
}
diff --git a/input/command.go b/input/command.go
index 0900d8d..57fc8a8 100644
--- a/input/command.go
+++ b/input/command.go
@@ -1,79 +1,70 @@
package input
import (
- "errors"
"fmt"
+
"os"
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/textinput"
+ "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/cursor"
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+
"github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for the text input bubble.
// https://github.com/charmbracelet/bubbles/textinput
func (o Options) Run() error {
- if o.Value == "" {
- if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" {
- o.Value = in
- }
+ var value string
+ if o.Value != "" {
+ value = o.Value
+ } else if in, _ := stdin.Read(); in != "" {
+ value = in
}
- i := textinput.New()
- if o.Value != "" {
- i.SetValue(o.Value)
- } else if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); 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"))
+
+ var echoMode huh.EchoMode
if o.Password {
- i.EchoMode = textinput.EchoPassword
- i.EchoCharacter = '•'
+ echoMode = huh.EchoModePassword
+ } else {
+ echoMode = huh.EchoModeNormal
}
- top, right, bottom, left := style.ParsePadding(o.Padding)
- m := model{
- textinput: i,
- header: o.Header,
- headerStyle: o.HeaderStyle.ToLipgloss(),
- padding: []int{top, right, bottom, left},
- autoWidth: o.Width < 1,
- showHelp: o.ShowHelp,
- help: help.New(),
- keymap: defaultKeymap(),
- }
+ 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).
+ WithShowHelp(o.ShowHelp).
+ WithProgramOptions(tea.WithOutput(os.Stderr)).
+ Run()
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- p := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithReportFocus(),
- tea.WithContext(ctx),
- )
- tm, err := p.Run()
if err != nil {
- return fmt.Errorf("failed to run input: %w", err)
+ return err
}
- m = tm.(model)
- if !m.submitted {
- return errors.New("not submitted")
- }
- fmt.Println(m.textinput.Value())
+ fmt.Println(value)
return nil
}
diff --git a/input/input.go b/input/input.go
deleted file mode 100644
index f63ac6a..0000000
--- a/input/input.go
+++ /dev/null
@@ -1,100 +0,0 @@
-// 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 (
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "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
- padding []int
- headerStyle lipgloss.Style
- textinput textinput.Model
- quitting bool
- submitted bool
- showHelp bool
- help help.Model
- keymap keymap
-}
-
-func (m model) Init() tea.Cmd { return textinput.Blink }
-
-func (m model) View() string {
- if m.quitting {
- return ""
- }
- var parts []string
- if m.header != "" {
- parts = append(parts, m.headerStyle.Render(m.header))
- }
-
- parts = append(parts, m.textinput.View())
- if m.showHelp {
- parts = append(parts, "", m.help.View(m.keymap))
- }
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(lipgloss.JoinVertical(
- lipgloss.Top,
- parts...,
- ))
-}
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- if m.autoWidth {
- m.textinput.Width = msg.Width - 1 -
- lipgloss.Width(m.textinput.Prompt) -
- m.padding[1] - m.padding[3]
- }
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c":
- m.quitting = true
- return m, tea.Interrupt
- case "esc":
- m.quitting = true
- return m, tea.Quit
- case "enter":
- m.quitting = true
- m.submitted = 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 57cbf53..7c68889 100644
--- a/input/options.go
+++ b/input/options.go
@@ -16,12 +16,10 @@ type Options struct {
CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_INPUT_CURSOR_MODE"`
Value string `help:"Initial value (can also be passed via stdin)" default:""`
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"`
+ Width int `help:"Input width (0 for terminal width)" default:"40" env:"GUM_INPUT_WIDTH"`
Password bool `help:"Mask input characters" default:"false"`
- ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_INPUT_SHOW_HELP"`
+ ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" 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"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_INPUT_STRIP_ANSI"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_INPUT_PADDING"`
+ Timeout time.Duration `help:"Timeout until input aborts" default:"0" env:"GUM_INPUT_TIMEOUT"`
}
diff --git a/internal/decode/align.go b/internal/decode/align.go
index 555f13c..813bcdd 100644
--- a/internal/decode/align.go
+++ b/internal/decode/align.go
@@ -1,4 +1,3 @@
-// Package decode position strings to lipgloss.
package decode
import "github.com/charmbracelet/lipgloss"
diff --git a/internal/exit/exit.go b/internal/exit/exit.go
index f523efc..ff3a2b9 100644
--- a/internal/exit/exit.go
+++ b/internal/exit/exit.go
@@ -1,16 +1,9 @@
-// Package exit code implementation.
package exit
-import "strconv"
-
-// StatusTimeout is the exit code for timed out commands.
-const StatusTimeout = 124
+import "fmt"
// StatusAborted is the exit code for aborted commands.
const StatusAborted = 130
-// ErrExit is a custom exit error.
-type ErrExit int
-
-// Error implements error.
-func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) }
+// ErrAborted is the error to return when a gum command is aborted by Ctrl + C.
+var ErrAborted = fmt.Errorf("aborted")
diff --git a/internal/files/files.go b/internal/files/files.go
index d1cd19e..51e6950 100644
--- a/internal/files/files.go
+++ b/internal/files/files.go
@@ -1,4 +1,3 @@
-// Package files handles files.
package files
import (
@@ -19,6 +18,7 @@ func List() []string {
files = append(files, path)
return nil
})
+
if err != nil {
return []string{}
}
diff --git a/internal/log/log.go b/internal/log/log.go
new file mode 100644
index 0000000..2a9f9a9
--- /dev/null
+++ b/internal/log/log.go
@@ -0,0 +1,8 @@
+package log
+
+import "fmt"
+
+// Error prints an error message to the user.
+func Error(message string) {
+ fmt.Println("Error:", message)
+}
diff --git a/internal/stack/stack.go b/internal/stack/stack.go
new file mode 100644
index 0000000..b28fedb
--- /dev/null
+++ b/internal/stack/stack.go
@@ -0,0 +1,26 @@
+package stack
+
+// Stack is a stack interface for integers.
+type Stack struct {
+ Push func(int)
+ Pop func() int
+ Length func() int
+}
+
+// NewStack returns a new stack of integers.
+func NewStack() Stack {
+ slice := make([]int, 0)
+ return Stack{
+ Push: func(i int) {
+ slice = append(slice, i)
+ },
+ Pop: func() int {
+ res := slice[len(slice)-1]
+ slice = slice[:len(slice)-1]
+ return res
+ },
+ Length: func() int {
+ return len(slice)
+ },
+ }
+}
diff --git a/internal/stdin/stdin.go b/internal/stdin/stdin.go
index 2efdcfd..1a1b0f3 100644
--- a/internal/stdin/stdin.go
+++ b/internal/stdin/stdin.go
@@ -1,4 +1,3 @@
-// Package stdin handles processing input from stdin.
package stdin
import (
@@ -7,58 +6,18 @@ import (
"io"
"os"
"strings"
-
- "github.com/charmbracelet/x/ansi"
)
-type options struct {
- ansiStrip bool
- singleLine bool
-}
-
-// Option is a read option.
-type Option func(*options)
-
-// StripANSI optionally strips ansi sequences.
-func StripANSI(b bool) Option {
- return func(o *options) {
- o.ansiStrip = b
- }
-}
-
-// SingleLine reads a single line.
-func SingleLine(b bool) Option {
- return func(o *options) {
- o.singleLine = b
- }
-}
-
// Read reads input from an stdin pipe.
-func Read(opts ...Option) (string, error) {
+func Read() (string, error) {
if IsEmpty() {
return "", fmt.Errorf("stdin is empty")
}
- options := options{}
- for _, opt := range opts {
- opt(&options)
- }
-
reader := bufio.NewReader(os.Stdin)
var b strings.Builder
- if options.singleLine {
- line, _, err := reader.ReadLine()
- if err != nil {
- return "", fmt.Errorf("failed to read line: %w", err)
- }
- _, err = b.Write(line)
- if err != nil {
- return "", fmt.Errorf("failed to write: %w", err)
- }
- }
-
- for !options.singleLine {
+ for {
r, _, err := reader.ReadRune()
if err != nil && err == io.EOF {
break
@@ -69,11 +28,7 @@ func Read(opts ...Option) (string, error) {
}
}
- s := strings.TrimSpace(b.String())
- if options.ansiStrip {
- return ansi.Strip(s), nil
- }
- return s, nil
+ return strings.TrimSuffix(b.String(), "\n"), nil
}
// IsEmpty returns whether stdin is empty.
diff --git a/internal/timeout/context.go b/internal/timeout/context.go
deleted file mode 100644
index ffd39e9..0000000
--- a/internal/timeout/context.go
+++ /dev/null
@@ -1,16 +0,0 @@
-// Package timeout handles context timeouts.
-package timeout
-
-import (
- "context"
- "time"
-)
-
-// Context setup a new context that times out if the given timeout is > 0.
-func Context(timeout time.Duration) (context.Context, context.CancelFunc) {
- ctx := context.Background()
- if timeout == 0 {
- return ctx, func() {}
- }
- return context.WithTimeout(ctx, timeout)
-}
diff --git a/internal/tty/tty.go b/internal/tty/tty.go
deleted file mode 100644
index 75b8237..0000000
--- a/internal/tty/tty.go
+++ /dev/null
@@ -1,24 +0,0 @@
-// Package tty provides tty-aware printing.
-package tty
-
-import (
- "fmt"
- "os"
- "sync"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/term"
-)
-
-var isTTY = sync.OnceValue(func() bool {
- return term.IsTerminal(os.Stdout.Fd())
-})
-
-// Println handles println, striping ansi sequences if stdout is not a tty.
-func Println(s string) {
- if isTTY() {
- fmt.Println(s)
- return
- }
- fmt.Println(ansi.Strip(s))
-}
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
new file mode 100644
index 0000000..0e38598
--- /dev/null
+++ b/internal/utils/utils.go
@@ -0,0 +1,15 @@
+package utils
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// LipglossPadding calculates how much padding a string is given by a style.
+func LipglossPadding(style lipgloss.Style) (int, int) {
+ render := style.Render(" ")
+ before := strings.Index(render, " ")
+ after := len(render) - len(" ") - before
+ return before, after
+}
diff --git a/log/command.go b/log/command.go
index 1240083..7ea959e 100644
--- a/log/command.go
+++ b/log/command.go
@@ -1,4 +1,3 @@
-// Package log the log command.
package log
import (
@@ -17,7 +16,7 @@ func (o Options) Run() error {
l := log.New(os.Stderr)
if o.File != "" {
- f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm) //nolint:gosec
+ f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
@@ -29,13 +28,6 @@ func (o Options) Run() error {
l.SetPrefix(o.Prefix)
l.SetLevel(-math.MaxInt32) // log all levels
l.SetReportTimestamp(o.Time != "")
- if o.MinLevel != "" {
- lvl, err := log.ParseLevel(o.MinLevel)
- if err != nil {
- return err //nolint:wrapcheck
- }
- l.SetLevel(lvl)
- }
timeFormats := map[string]string{
"layout": time.Layout,
diff --git a/log/options.go b/log/options.go
index 12949e3..9a19f36 100644
--- a/log/options.go
+++ b/log/options.go
@@ -16,9 +16,7 @@ type Options struct {
Structured bool `short:"s" help:"Use structured logging" xor:"format,structured"`
Time string `short:"t" help:"The time format to use (kitchen, layout, ansic, rfc822, etc...)" default:""`
- MinLevel string `help:"Minimal level to show" default:"" env:"GUM_LOG_LEVEL"`
-
- LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"`
+ LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"` //nolint:staticcheck
TimeStyle style.Styles `embed:"" prefix:"time." help:"The style of the time" envprefix:"GUM_LOG_TIME_"`
PrefixStyle style.Styles `embed:"" prefix:"prefix." help:"The style of the prefix" set:"defaultBold=true" set:"defaultFaint=true" envprefix:"GUM_LOG_PREFIX_"` //nolint:staticcheck
MessageStyle style.Styles `embed:"" prefix:"message." help:"The style of the message" envprefix:"GUM_LOG_MESSAGE_"`
diff --git a/main.go b/main.go
index a61916e..b8f3407 100644
--- a/main.go
+++ b/main.go
@@ -1,4 +1,3 @@
-// Package main is Gum: a tool for glamorous shell scripts.
package main
import (
@@ -8,10 +7,11 @@ import (
"runtime/debug"
"github.com/alecthomas/kong"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/internal/exit"
+ "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
+
+ "github.com/charmbracelet/gum/internal/exit"
)
const shaLen = 7
@@ -55,7 +55,6 @@ func main() {
}),
kong.Vars{
"version": version,
- "versionNumber": Version,
"defaultHeight": "0",
"defaultWidth": "0",
"defaultAlign": "left",
@@ -74,18 +73,10 @@ func main() {
},
)
if err := ctx.Run(); err != nil {
- var ex exit.ErrExit
- if errors.As(err, &ex) {
- os.Exit(int(ex))
- }
- if errors.Is(err, tea.ErrInterrupted) {
+ if errors.Is(err, exit.ErrAborted) || errors.Is(err, huh.ErrUserAborted) {
os.Exit(exit.StatusAborted)
}
- if errors.Is(err, tea.ErrProgramKilled) {
- fmt.Fprintln(os.Stderr, "timed out")
- os.Exit(exit.StatusTimeout)
- }
- fmt.Fprintln(os.Stderr, err)
+ fmt.Println(err)
os.Exit(1)
}
}
diff --git a/man/command.go b/man/command.go
index 22f5bec..1d83dab 100644
--- a/man/command.go
+++ b/man/command.go
@@ -1,4 +1,3 @@
-// Package man the man command.
package man
import (
diff --git a/pager/command.go b/pager/command.go
index 414bee7..5131b96 100644
--- a/pager/command.go
+++ b/pager/command.go
@@ -4,11 +4,9 @@ import (
"fmt"
"regexp"
- "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
)
// Run provides a shell script interface for the viewport bubble.
@@ -31,9 +29,9 @@ func (o Options) Run() error {
}
}
- m := model{
+ model := model{
viewport: vp,
- help: help.New(),
+ helpStyle: o.HelpStyle.ToLipgloss(),
content: o.Content,
origContent: o.Content,
showLineNumbers: o.ShowLineNumbers,
@@ -41,21 +39,12 @@ func (o Options) Run() error {
softWrap: o.SoftWrap,
matchStyle: o.MatchStyle.ToLipgloss(),
matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(),
- keymap: defaultKeymap(),
+ timeout: o.Timeout,
+ hasTimeout: o.Timeout > 0,
}
-
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- _, err := tea.NewProgram(
- m,
- tea.WithAltScreen(),
- tea.WithReportFocus(),
- tea.WithContext(ctx),
- ).Run()
+ _, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil {
return fmt.Errorf("unable to start program: %w", err)
}
-
return nil
}
diff --git a/pager/options.go b/pager/options.go
index a44fca7..f257cb0 100644
--- a/pager/options.go
+++ b/pager/options.go
@@ -10,14 +10,12 @@ import (
type Options struct {
//nolint:staticcheck
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
+ HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
Content string `arg:"" optional:"" help:"Display content to scroll"`
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
- SoftWrap bool `help:"Soft wrap lines" default:"true" negatable:""`
+ SoftWrap bool `help:"Soft wrap lines" default:"false"`
MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck
MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck
- Timeout time.Duration `help:"Timeout until command exits" default:"0s" env:"GUM_PAGER_TIMEOUT"`
-
- // Deprecated: this has no effect anymore.
- HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_" hidden:""`
+ Timeout time.Duration `help:"Timeout until command exits" default:"0" env:"GUM_PAGER_TIMEOUT"`
}
diff --git a/pager/pager.go b/pager/pager.go
index 324a314..ca24fc6 100644
--- a/pager/pager.go
+++ b/pager/pager.go
@@ -6,93 +6,21 @@ package pager
import (
"fmt"
"strings"
+ "time"
+
+ "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"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
+ "github.com/muesli/reflow/truncate"
)
-type keymap struct {
- Home,
- End,
- Search,
- NextMatch,
- PrevMatch,
- Abort,
- Quit,
- ConfirmSearch,
- CancelSearch 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{
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("↓↑", "navigate"),
- ),
- k.Quit,
- k.Search,
- k.NextMatch,
- k.PrevMatch,
- }
-}
-
-func defaultKeymap() keymap {
- return keymap{
- Home: key.NewBinding(
- key.WithKeys("g", "home"),
- key.WithHelp("h", "home"),
- ),
- End: key.NewBinding(
- key.WithKeys("G", "end"),
- key.WithHelp("G", "end"),
- ),
- Search: key.NewBinding(
- key.WithKeys("/"),
- key.WithHelp("/", "search"),
- ),
- PrevMatch: key.NewBinding(
- key.WithKeys("p", "N"),
- key.WithHelp("N", "previous match"),
- ),
- NextMatch: key.NewBinding(
- key.WithKeys("n"),
- key.WithHelp("n", "next match"),
- ),
- Abort: key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "abort"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("q", "esc"),
- key.WithHelp("esc", "quit"),
- ),
- ConfirmSearch: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "confirm"),
- ),
- CancelSearch: key.NewBinding(
- key.WithKeys("ctrl+c", "ctrl+d", "esc"),
- key.WithHelp("ctrl+c", "cancel"),
- ),
- }
-}
-
type model struct {
content string
origContent string
viewport viewport.Model
- help help.Model
+ helpStyle lipgloss.Style
showLineNumbers bool
lineNumberStyle lipgloss.Style
softWrap bool
@@ -100,33 +28,34 @@ type model struct {
matchStyle lipgloss.Style
matchHighlightStyle lipgloss.Style
maxWidth int
- keymap keymap
+ timeout time.Duration
+ hasTimeout bool
}
-func (m model) Init() tea.Cmd { return nil }
+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 timeout.TickTimeoutMsg:
+ if msg.TimeoutValue <= 0 {
+ return m, tea.Quit
+ }
+ m.timeout = msg.TimeoutValue
+ return m, timeout.Tick(msg.TimeoutValue, msg.Data)
+
case tea.WindowSizeMsg:
- m.processText(msg)
+ m.ProcessText(msg)
case tea.KeyMsg:
- return m.keyHandler(msg)
+ return m.KeyHandler(msg)
}
- m.keymap.PrevMatch.SetEnabled(m.search.query != nil)
- m.keymap.NextMatch.SetEnabled(m.search.query != nil)
-
- var cmd tea.Cmd
- m.search.input, cmd = m.search.input.Update(msg)
- return m, cmd
+ return m, nil
}
-func (m *model) helpView() string {
- return m.help.View(m.keymap)
-}
-
-func (m *model) processText(msg tea.WindowSizeMsg) {
- m.viewport.Height = msg.Height - lipgloss.Height(m.helpView())
+func (m *model) ProcessText(msg tea.WindowSizeMsg) {
+ m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1
m.viewport.Width = msg.Width
textStyle := lipgloss.NewStyle().Width(m.viewport.Width)
var text strings.Builder
@@ -146,21 +75,17 @@ func (m *model) processText(msg tea.WindowSizeMsg) {
if m.showLineNumbers {
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
}
- idx := 0
- if w := ansi.StringWidth(line); m.softWrap && w > m.maxWidth {
- for w > idx {
- if m.showLineNumbers && idx != 0 {
- text.WriteString(m.lineNumberStyle.Render(" │ "))
- }
- truncatedLine := ansi.Cut(line, idx, m.maxWidth+idx)
- idx += m.maxWidth
- text.WriteString(textStyle.Render(truncatedLine))
- text.WriteString("\n")
- }
- } else {
- text.WriteString(textStyle.Render(line))
+ for m.softWrap && lipgloss.Width(line) > m.maxWidth {
+ truncatedLine := truncate.String(line, uint(m.maxWidth))
+ text.WriteString(textStyle.Render(truncatedLine))
text.WriteString("\n")
+ if m.showLineNumbers {
+ text.WriteString(m.lineNumberStyle.Render(" │ "))
+ }
+ line = strings.Replace(line, truncatedLine, "", 1)
}
+ text.WriteString(textStyle.Render(truncate.String(line, uint(m.maxWidth))))
+ text.WriteString("\n")
}
diffHeight := m.viewport.Height - lipgloss.Height(text.String())
@@ -173,57 +98,62 @@ func (m *model) processText(msg tea.WindowSizeMsg) {
const heightOffset = 2
-func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) {
- km := m.keymap
+func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) {
var cmd tea.Cmd
if m.search.active {
- switch {
- case key.Matches(msg, km.ConfirmSearch):
+ switch key.String() {
+ case "enter":
if m.search.input.Value() != "" {
m.content = m.origContent
m.search.Execute(&m)
// Trigger a view update to highlight the found matches.
m.search.NextMatch(&m)
- m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
+ m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
} else {
m.search.Done()
}
- case key.Matches(msg, km.CancelSearch):
+ case "ctrl+d", "ctrl+c", "esc":
m.search.Done()
default:
- m.search.input, cmd = m.search.input.Update(msg)
+ m.search.input, cmd = m.search.input.Update(key)
}
} else {
- switch {
- case key.Matches(msg, km.Home):
+ switch key.String() {
+ case "g", "home":
m.viewport.GotoTop()
- case key.Matches(msg, km.End):
+ case "G", "end":
m.viewport.GotoBottom()
- case key.Matches(msg, km.Search):
+ case "/":
m.search.Begin()
- return m, textinput.Blink
- case key.Matches(msg, km.PrevMatch):
+ case "p", "N":
m.search.PrevMatch(&m)
- m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
- case key.Matches(msg, km.NextMatch):
+ m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
+ case "n":
m.search.NextMatch(&m)
- m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
- case key.Matches(msg, km.Quit):
+ m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
+ case "q", "ctrl+c", "esc":
return m, tea.Quit
- case key.Matches(msg, km.Abort):
- return m, tea.Interrupt
}
- m.viewport, cmd = m.viewport.Update(msg)
+ m.viewport, cmd = m.viewport.Update(key)
}
return m, cmd
}
func (m model) View() string {
+ var timeoutStr string
+ if m.hasTimeout {
+ timeoutStr = timeout.Str(m.timeout) + " "
+ }
+ helpMsg := "\n" + timeoutStr + " ↑/↓: Navigate • q: Quit • /: Search "
+ if m.search.query != nil {
+ helpMsg += "• n: Next Match "
+ helpMsg += "• N: Prev Match "
+ }
if m.search.active {
- return m.viewport.View() + "\n " + m.search.input.View()
+ return m.viewport.View() + "\n" + timeoutStr + " " + m.search.input.View()
}
- return m.viewport.View() + "\n" + m.helpView()
+ return m.viewport.View() + m.helpStyle.Render(helpMsg)
}
diff --git a/pager/search.go b/pager/search.go
index 134096c..b112d7d 100644
--- a/pager/search.go
+++ b/pager/search.go
@@ -6,8 +6,9 @@ import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
+ "github.com/charmbracelet/gum/internal/utils"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
+ "github.com/muesli/reflow/truncate"
)
type search struct {
@@ -51,7 +52,7 @@ func (s *search) Execute(m *model) {
m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1"))
// Recompile the regex to match the an replace the highlights.
- leftPad, _ := lipglossPadding(m.matchStyle)
+ leftPad, _ := utils.LipglossPadding(m.matchStyle)
matchingString := regexp.QuoteMeta(m.matchStyle.Render()[:leftPad]) + s.query.String() + regexp.QuoteMeta(m.matchStyle.Render()[leftPad:])
s.query, err = regexp.Compile(matchingString)
if err != nil {
@@ -81,7 +82,7 @@ func (s *search) NextMatch(m *model) {
return
}
- leftPad, rightPad := lipglossPadding(m.matchStyle)
+ leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
s.matchIndex = (s.matchIndex + 1) % len(allMatches)
match := allMatches[s.matchIndex]
lhs := m.content[:match[0]]
@@ -124,7 +125,7 @@ func (s *search) PrevMatch(m *model) {
s.matchIndex = len(allMatches) - 1
}
- leftPad, rightPad := lipglossPadding(m.matchStyle)
+ leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
match := allMatches[s.matchIndex]
lhs := m.content[:match[0]]
rhs := m.content[match[0]:]
@@ -149,27 +150,15 @@ func (s *search) PrevMatch(m *model) {
func softWrapEm(str string, maxWidth int, softWrap bool) string {
var text strings.Builder
for _, line := range strings.Split(str, "\n") {
- idx := 0
- if w := ansi.StringWidth(line); softWrap && w > maxWidth {
- for w > idx {
- truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
- idx += maxWidth
- text.WriteString(truncatedLine)
- text.WriteString("\n")
- }
- } else {
- text.WriteString(line)
+ for softWrap && lipgloss.Width(line) > maxWidth {
+ truncatedLine := truncate.String(line, uint(maxWidth))
+ text.WriteString(truncatedLine)
text.WriteString("\n")
+ line = strings.Replace(line, truncatedLine, "", 1)
}
+ text.WriteString(truncate.String(line, uint(maxWidth)))
+ text.WriteString("\n")
}
return text.String()
}
-
-// lipglossPadding calculates how much padding a string is given by a style.
-func lipglossPadding(style lipgloss.Style) (int, int) {
- render := style.Render(" ")
- before := strings.Index(render, " ")
- after := len(render) - len(" ") - before
- return before, after
-}
diff --git a/spin/command.go b/spin/command.go
index cff2797..6bde58d 100644
--- a/spin/command.go
+++ b/spin/command.go
@@ -6,77 +6,64 @@ import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/internal/exit"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/style"
"github.com/charmbracelet/x/term"
+
+ "github.com/charmbracelet/gum/internal/exit"
)
// Run provides a shell script interface for the spinner bubble.
// https://github.com/charmbracelet/bubbles/spinner
func (o Options) Run() error {
- isOutTTY := term.IsTerminal(os.Stdout.Fd())
- isErrTTY := term.IsTerminal(os.Stderr.Fd())
+ isTTY := term.IsTerminal(os.Stdout.Fd())
s := spinner.New()
s.Style = o.SpinnerStyle.ToLipgloss()
s.Spinner = spinnerMap[o.Spinner]
- top, right, bottom, left := style.ParsePadding(o.Padding)
m := model{
spinner: s,
title: o.TitleStyle.ToLipgloss().Render(o.Title),
command: o.Command,
align: o.Align,
- showStdout: (o.ShowOutput || o.ShowStdout) && isOutTTY,
- showStderr: (o.ShowOutput || o.ShowStderr) && isErrTTY,
+ showOutput: o.ShowOutput && isTTY,
showError: o.ShowError,
- isTTY: isErrTTY,
- padding: []int{top, right, bottom, left},
+ timeout: o.Timeout,
+ hasTimeout: o.Timeout > 0,
}
+ p := tea.NewProgram(m, tea.WithOutput(os.Stderr))
+ mm, err := p.Run()
+ m = mm.(model)
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- tm, err := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithContext(ctx),
- tea.WithInput(nil),
- ).Run()
if err != nil {
- return fmt.Errorf("unable to run action: %w", err)
+ return fmt.Errorf("failed to run spin: %w", err)
+ }
+
+ if m.aborted {
+ return exit.ErrAborted
}
- m = tm.(model)
// 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.
//nolint:nestif
- if m.err != nil {
- if _, err := fmt.Fprintf(os.Stderr, "%s\n", m.err.Error()); err != nil {
- return fmt.Errorf("failed to write to stdout: %w", err)
- }
- return exit.ErrExit(1)
- } else if m.status == 0 {
- var output string
- if o.ShowOutput || (o.ShowStdout && o.ShowStderr) {
- output = m.output
- } else if o.ShowStdout {
- output = m.stdout
- } else if o.ShowStderr {
- output = m.stderr
- }
- if output != "" {
- if _, err := os.Stdout.WriteString(output); err != nil {
- return fmt.Errorf("failed to write to stdout: %w", err)
+ if m.status == 0 {
+ if o.ShowOutput {
+ // BubbleTea writes the View() to stderr.
+ // If the program is being piped then put the accumulated output in stdout.
+ if !isTTY {
+ _, err := os.Stdout.WriteString(m.stdout)
+ if err != nil {
+ return fmt.Errorf("failed to write to stdout: %w", err)
+ }
}
}
} else if o.ShowError {
// Otherwise if we are showing errors and the command did not exit with a 0 status code then push all of the command
// output to the terminal. This way failed commands can be debugged.
- if _, err := os.Stdout.WriteString(m.output); err != nil {
+ _, err := os.Stdout.WriteString(m.output)
+ if err != nil {
return fmt.Errorf("failed to write to stdout: %w", err)
}
}
- return exit.ErrExit(m.status)
+ os.Exit(m.status)
+ return nil
}
diff --git a/spin/options.go b/spin/options.go
index 702cc2a..542ff2b 100644
--- a/spin/options.go
+++ b/spin/options.go
@@ -10,15 +10,12 @@ import (
type Options struct {
Command []string `arg:"" help:"Command to run"`
- ShowOutput bool `help:"Show or pipe output of command during execution (shows both STDOUT and STDERR)" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
+ ShowOutput bool `help:"Show or pipe output of command during execution" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
ShowError bool `help:"Show output of command only if the command fails" default:"false" env:"GUM_SPIN_SHOW_ERROR"`
- ShowStdout bool `help:"Show STDOUT output" default:"false" env:"GUM_SPIN_SHOW_STDOUT"`
- ShowStderr bool `help:"Show STDERR errput" default:"false" env:"GUM_SPIN_SHOW_STDERR"`
Spinner string `help:"Spinner type" short:"s" type:"spinner" enum:"line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger" default:"dot" env:"GUM_SPIN_SPINNER"`
SpinnerStyle style.Styles `embed:"" prefix:"spinner." set:"defaultForeground=212" envprefix:"GUM_SPIN_SPINNER_"`
Title string `help:"Text to display to user while spinning" default:"Loading..." env:"GUM_SPIN_TITLE"`
TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_SPIN_TITLE_"`
Align string `help:"Alignment of spinner with regard to the title" short:"a" type:"align" enum:"left,right" default:"left" env:"GUM_SPIN_ALIGN"`
- Timeout time.Duration `help:"Timeout until spin command aborts" default:"0s" env:"GUM_SPIN_TIMEOUT"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_SPIN_PADDING"`
+ Timeout time.Duration `help:"Timeout until spin command aborts" default:"0" env:"GUM_SPIN_TIMEOUT"`
}
diff --git a/spin/pty.go b/spin/pty.go
deleted file mode 100644
index 9562434..0000000
--- a/spin/pty.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package spin
-
-import (
- "os"
-
- "github.com/charmbracelet/x/term"
- "github.com/charmbracelet/x/xpty"
-)
-
-func openPty(f *os.File) (pty xpty.Pty, err error) {
- width, height, err := term.GetSize(f.Fd())
- if err != nil {
- return nil, err //nolint:wrapcheck
- }
-
- pty, err = xpty.NewPty(width, height)
- if err != nil {
- return nil, err //nolint:wrapcheck
- }
-
- return pty, nil
-}
diff --git a/spin/spin.go b/spin/spin.go
index 6e41690..db63fa4 100644
--- a/spin/spin.go
+++ b/spin/spin.go
@@ -15,49 +15,43 @@
package spin
import (
- "bytes"
- "context"
"io"
"os"
"os/exec"
- "runtime"
- "syscall"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/gum/internal/exit"
+ "github.com/charmbracelet/gum/timeout"
+ "github.com/charmbracelet/x/term"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/term"
- "github.com/charmbracelet/x/xpty"
)
type model struct {
spinner spinner.Model
title string
- padding []int
align string
command []string
quitting bool
- isTTY bool
+ aborted bool
status int
stdout string
stderr string
output string
- showStdout bool
- showStderr bool
+ showOutput bool
showError bool
- err error
+ timeout time.Duration
+ hasTimeout bool
}
var (
- bothbuf bytes.Buffer
- outbuf bytes.Buffer
- errbuf bytes.Buffer
-
- executing *exec.Cmd
+ bothbuf strings.Builder
+ outbuf strings.Builder
+ errbuf strings.Builder
)
-type errorMsg error
-
type finishCommandMsg struct {
stdout string
stderr string
@@ -71,54 +65,22 @@ func commandStart(command []string) tea.Cmd {
if len(command) > 1 {
args = command[1:]
}
+ cmd := exec.Command(command[0], args...) //nolint:gosec
- executing = exec.CommandContext(context.Background(), command[0], args...) //nolint:gosec
- executing.Stdin = os.Stdin
+ if term.IsTerminal(os.Stdout.Fd()) {
+ stdout := io.MultiWriter(&bothbuf, &errbuf)
+ stderr := io.MultiWriter(&bothbuf, &outbuf)
- isTerminal := term.IsTerminal(os.Stdout.Fd())
-
- // NOTE(@andreynering): We had issues with Git Bash on Windows
- // when it comes to handling PTYs, so we're falling back to
- // to redirecting stdout/stderr as usual to avoid issues.
- //nolint:nestif
- if isTerminal && runtime.GOOS == "windows" {
- executing.Stdout = io.MultiWriter(&bothbuf, &outbuf)
- executing.Stderr = io.MultiWriter(&bothbuf, &errbuf)
- _ = executing.Run()
- } else if isTerminal {
- stdoutPty, err := openPty(os.Stdout)
- if err != nil {
- return errorMsg(err)
- }
- defer stdoutPty.Close() //nolint:errcheck
-
- stderrPty, err := openPty(os.Stderr)
- if err != nil {
- return errorMsg(err)
- }
- defer stderrPty.Close() //nolint:errcheck
-
- if outUnixPty, isOutUnixPty := stdoutPty.(*xpty.UnixPty); isOutUnixPty {
- executing.Stdout = outUnixPty.Slave()
- }
- if errUnixPty, isErrUnixPty := stderrPty.(*xpty.UnixPty); isErrUnixPty {
- executing.Stderr = errUnixPty.Slave()
- }
-
- go io.Copy(io.MultiWriter(&bothbuf, &outbuf), stdoutPty) //nolint:errcheck
- go io.Copy(io.MultiWriter(&bothbuf, &errbuf), stderrPty) //nolint:errcheck
-
- if err = stdoutPty.Start(executing); err != nil {
- return errorMsg(err)
- }
- _ = xpty.WaitProcess(context.Background(), executing)
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
} else {
- executing.Stdout = os.Stdout
- executing.Stderr = os.Stderr
- _ = executing.Run()
+ cmd.Stdout = os.Stdout
}
- status := executing.ProcessState.ExitCode()
+ _ = cmd.Run()
+
+ status := cmd.ProcessState.ExitCode()
+
if status == -1 {
status = 1
}
@@ -132,50 +94,48 @@ func commandStart(command []string) tea.Cmd {
}
}
-func commandAbort() tea.Msg {
- if executing != nil && executing.Process != nil {
- _ = executing.Process.Signal(syscall.SIGINT)
- }
- return tea.InterruptMsg{}
-}
-
func (m model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
commandStart(m.command),
+ timeout.Init(m.timeout, nil),
)
}
func (m model) View() string {
- if m.quitting {
- return ""
+ if m.quitting && m.showOutput {
+ return strings.TrimPrefix(errbuf.String()+"\n"+outbuf.String(), "\n")
}
- var out string
- if m.showStderr {
- out += errbuf.String()
+ var str string
+ if m.hasTimeout {
+ str = timeout.Str(m.timeout)
}
- if m.showStdout {
- out += outbuf.String()
- }
-
- if !m.isTTY {
- return m.title
- }
-
var header string
if m.align == "left" {
- header = m.spinner.View() + " " + m.title
+ header = m.spinner.View() + str + " " + m.title
} else {
- header = m.title + " " + m.spinner.View()
+ header = str + " " + m.title + " " + m.spinner.View()
}
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(header, "", out)
+ if !m.showOutput {
+ return header
+ }
+ return header + errbuf.String() + "\n" + outbuf.String()
}
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 {
+ // grab current output before closing for piped instances
+ m.stdout = outbuf.String()
+
+ m.status = exit.StatusAborted
+ return m, tea.Quit
+ }
+ m.timeout = msg.TimeoutValue
+ return m, timeout.Tick(msg.TimeoutValue, msg.Data)
case finishCommandMsg:
m.stdout = msg.stdout
m.stderr = msg.stderr
@@ -186,15 +146,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
- return m, commandAbort
+ m.aborted = true
+ return m, tea.Quit
}
- case errorMsg:
- m.err = msg
- m.quitting = true
- return m, tea.Quit
}
- var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
diff --git a/style/ascii_a.txt b/style/ascii_a.txt
deleted file mode 100644
index ba425ec..0000000
--- a/style/ascii_a.txt
+++ /dev/null
@@ -1,7 +0,0 @@
- #
- # #
- # #
-# #
-#######
-# #
-# #
diff --git a/style/command.go b/style/command.go
index 0263211..e698df9 100644
--- a/style/command.go
+++ b/style/command.go
@@ -20,18 +20,11 @@ func (o Options) Run() error {
if len(o.Text) > 0 {
text = strings.Join(o.Text, "\n")
} else {
- text, _ = stdin.Read(stdin.StripANSI(o.StripANSI))
+ text, _ = stdin.Read()
if text == "" {
return errors.New("no input provided, see `gum style --help`")
}
}
- if o.Trim {
- var lines []string
- for _, line := range strings.Split(text, "\n") {
- lines = append(lines, strings.TrimSpace(line))
- }
- text = strings.Join(lines, "\n")
- }
fmt.Println(o.Style.ToLipgloss().Render(text))
return nil
}
diff --git a/style/lipgloss.go b/style/lipgloss.go
index 07ad0c8..9ba52a2 100644
--- a/style/lipgloss.go
+++ b/style/lipgloss.go
@@ -19,7 +19,7 @@ func (s Styles) ToLipgloss() lipgloss.Style {
Height(s.Height).
Width(s.Width).
Margin(parseMargin(s.Margin)).
- Padding(ParsePadding(s.Padding)).
+ Padding(parsePadding(s.Padding)).
Bold(s.Bold).
Faint(s.Faint).
Italic(s.Italic).
@@ -40,7 +40,7 @@ func (s StylesNotHidden) ToLipgloss() lipgloss.Style {
Height(s.Height).
Width(s.Width).
Margin(parseMargin(s.Margin)).
- Padding(ParsePadding(s.Padding)).
+ Padding(parsePadding(s.Padding)).
Bold(s.Bold).
Faint(s.Faint).
Italic(s.Italic).
diff --git a/style/options.go b/style/options.go
index 67545e5..0b250c2 100644
--- a/style/options.go
+++ b/style/options.go
@@ -2,10 +2,8 @@ package style
// Options is the customization options for the style command.
type Options struct {
- Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
- Trim bool `help:"Trim whitespaces on every input line" default:"false"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_STYLE_STRIP_ANSI"`
- Style StylesNotHidden `embed:""`
+ Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
+ Style StylesNotHidden `embed:""`
}
// Styles is a flag set of possible styles.
@@ -17,7 +15,7 @@ type Options struct {
type Styles struct {
// Colors
Foreground string `help:"Foreground Color" default:"${defaultForeground}" group:"Style Flags" env:"FOREGROUND"`
- Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND"`
+ Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND" hidden:"true"`
// Border
Border string `help:"Border Style" enum:"none,hidden,normal,rounded,thick,double" default:"${defaultBorder}" group:"Style Flags" env:"BORDER" hidden:"true"`
diff --git a/style/spacing.go b/style/spacing.go
index 57ea7db..6b3fe26 100644
--- a/style/spacing.go
+++ b/style/spacing.go
@@ -5,15 +5,13 @@ import (
"strings"
)
-const (
- minTokens = 1
- halfTokens = 2
- maxTokens = 4
-)
+const minTokens = 1
+const halfTokens = 2
+const maxTokens = 4
-// ParsePadding parses 1 - 4 integers from a string and returns them in a top,
+// parsePadding parses 1 - 4 integers from a string and returns them in a top,
// right, bottom, left order for use in the lipgloss.Padding() method.
-func ParsePadding(s string) (int, int, int, int) {
+func parsePadding(s string) (int, int, int, int) {
var ints [maxTokens]int
tokens := strings.Split(s, " ")
@@ -48,4 +46,4 @@ func ParsePadding(s string) (int, int, int, int) {
// parseMargin is an alias for parsePadding since they involve the same logic
// to parse integers to the same format.
-var parseMargin = ParsePadding
+var parseMargin = parsePadding
diff --git a/table/bom.csv b/table/bom.csv
deleted file mode 100644
index e492410..0000000
--- a/table/bom.csv
+++ /dev/null
@@ -1,4 +0,0 @@
-"first_name","last_name","username"
-"Rob","Pike",rob
-Ken,Thompson,ken
-"Robert","Griesemer","gri"
diff --git a/table/command.go b/table/command.go
index 479cd93..1e08faf 100644
--- a/table/command.go
+++ b/table/command.go
@@ -5,40 +5,31 @@ import (
"fmt"
"os"
- "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/gum/internal/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/style"
"github.com/charmbracelet/lipgloss"
ltable "github.com/charmbracelet/lipgloss/table"
- "golang.org/x/text/encoding"
- "golang.org/x/text/encoding/unicode"
- "golang.org/x/text/transform"
+
+ "github.com/charmbracelet/gum/internal/stdin"
+ "github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for rendering tabular data (CSV).
func (o Options) Run() error {
- var input *os.File
+ var reader *csv.Reader
if o.File != "" {
- var err error
- input, err = os.Open(o.File)
+ file, err := os.Open(o.File)
if err != nil {
- return fmt.Errorf("could not render file: %w", err)
+ return fmt.Errorf("could not find file at path %s", o.File)
}
+ reader = csv.NewReader(file)
} else {
if stdin.IsEmpty() {
return fmt.Errorf("no data provided")
}
- input = os.Stdin
+ reader = csv.NewReader(os.Stdin)
}
- defer input.Close() //nolint: errcheck
- transformer := unicode.BOMOverride(encoding.Nop.NewDecoder())
- reader := csv.NewReader(transform.NewReader(input, transformer))
- reader.LazyQuotes = o.LazyQuotes
- reader.FieldsPerRecord = o.FieldsPerRecord
separatorRunes := []rune(o.Separator)
if len(separatorRunes) != 1 {
return fmt.Errorf("separator must be single character")
@@ -79,7 +70,6 @@ func (o Options) Run() error {
}
defaultStyles := table.DefaultStyles()
- top, right, bottom, left := style.ParsePadding(o.Padding)
styles := table.Styles{
Cell: defaultStyles.Cell.Inherit(o.CellStyle.ToLipgloss()),
@@ -88,26 +78,11 @@ func (o Options) Run() error {
}
rows := make([]table.Row, 0, len(data))
- for row := range data {
- if len(data[row]) > len(columns) {
+ for _, row := range data {
+ if len(row) > len(columns) {
return fmt.Errorf("invalid number of columns")
}
-
- // fixes the data in case we have more columns than rows:
- for len(data[row]) < len(columns) {
- data[row] = append(data[row], "")
- }
-
- for i, col := range data[row] {
- if len(o.Widths) == 0 {
- width := lipgloss.Width(col)
- if width > columns[i].Width {
- columns[i].Width = width
- }
- }
- }
-
- rows = append(rows, table.Row(data[row]))
+ rows = append(rows, table.Row(row))
}
if o.Print {
@@ -127,34 +102,15 @@ func (o Options) Run() error {
return nil
}
- opts := []table.Option{
+ table := table.New(
table.WithColumns(columns),
table.WithFocused(true),
+ table.WithHeight(o.Height),
table.WithRows(rows),
table.WithStyles(styles),
- }
- if o.Height > 0 {
- opts = append(opts, table.WithHeight(o.Height-top-bottom))
- }
+ )
- table := table.New(opts...)
-
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- m := model{
- table: table,
- showHelp: o.ShowHelp,
- hideCount: o.HideCount,
- help: help.New(),
- keymap: defaultKeymap(),
- padding: []int{top, right, bottom, left},
- }
- tm, err := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithContext(ctx),
- ).Run()
+ tm, err := tea.NewProgram(model{table: table}, tea.WithOutput(os.Stderr)).Run()
if err != nil {
return fmt.Errorf("failed to start tea program: %w", err)
}
@@ -163,15 +119,10 @@ func (o Options) Run() error {
return fmt.Errorf("failed to get selection")
}
- m = tm.(model)
- if o.ReturnColumn > 0 && o.ReturnColumn <= len(m.selected) {
- if err = writer.Write([]string{m.selected[o.ReturnColumn-1]}); err != nil {
- return fmt.Errorf("failed to write col %d of selected row: %w", o.ReturnColumn, err)
- }
- } else {
- if err = writer.Write([]string(m.selected)); err != nil {
- return fmt.Errorf("failed to write selected row: %w", err)
- }
+ m := tm.(model)
+
+ if err = writer.Write([]string(m.selected)); err != nil {
+ return fmt.Errorf("failed to write selected row: %w", err)
}
writer.Flush()
diff --git a/table/options.go b/table/options.go
index d7a241f..b70578c 100644
--- a/table/options.go
+++ b/table/options.go
@@ -1,30 +1,19 @@
package table
-import (
- "time"
-
- "github.com/charmbracelet/gum/style"
-)
+import "github.com/charmbracelet/gum/style"
// Options is the customization options for the table command.
type Options struct {
- Separator string `short:"s" help:"Row separator" default:","`
- Columns []string `short:"c" help:"Column names"`
- Widths []int `short:"w" help:"Column widths"`
- Height int `help:"Table height" default:"0"`
- Print bool `short:"p" help:"static print" default:"false"`
- File string `short:"f" help:"file path" default:""`
- Border string `short:"b" help:"border style" default:"rounded" enum:"rounded,thick,normal,hidden,double,none"`
- ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_TABLE_SHOW_HELP"`
- HideCount bool `help:"Hide item count on help keybinds" default:"false" negatable:"" env:"GUM_TABLE_HIDE_COUNT"`
- LazyQuotes bool `help:"If LazyQuotes is true, a quote may appear in an unquoted field and a non-doubled quote may appear in a quoted field" default:"false" env:"GUM_TABLE_LAZY_QUOTES"`
- FieldsPerRecord int `help:"Sets the number of expected fields per record" default:"0" env:"GUM_TABLE_FIELDS_PER_RECORD"`
+ Separator string `short:"s" help:"Row separator" default:","`
+ Columns []string `short:"c" help:"Column names"`
+ Widths []int `short:"w" help:"Column widths"`
+ Height int `help:"Table height" default:"10"`
+ Print bool `short:"p" help:"static print" default:"false"`
+ File string `short:"f" help:"file path" default:""`
+ Border string `short:"b" help:"border style" default:"rounded" enum:"rounded,thick,normal,hidden,double,none"`
- BorderStyle style.Styles `embed:"" prefix:"border." envprefix:"GUM_TABLE_BORDER_"`
- CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL_"`
- HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER_"`
- SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED_"`
- ReturnColumn int `short:"r" help:"Which column number should be returned instead of whole row as string. Default=0 returns whole Row" default:"0"`
- Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_TABLE_TIMEOUT"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_TABLE_PADDING"`
+ BorderStyle style.Styles `embed:"" prefix:"border." envprefix:"GUM_TABLE_BORDER_"`
+ CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL_"`
+ HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER_"`
+ SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED_"`
}
diff --git a/table/table.go b/table/table.go
index c0d389f..d9a92ed 100644
--- a/table/table.go
+++ b/table/table.go
@@ -15,81 +15,18 @@
package table
import (
- "fmt"
- "strconv"
-
- "github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
)
-type keymap struct {
- Navigate,
- Select,
- Quit,
- Abort 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.Navigate,
- k.Select,
- k.Quit,
- }
-}
-
-func defaultKeymap() keymap {
- return keymap{
- Navigate: key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("↓↑", "navigate"),
- ),
- Select: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("esc", "ctrl+q", "q"),
- key.WithHelp("esc", "quit"),
- ),
- Abort: key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "abort"),
- ),
- }
-}
-
type model struct {
- table table.Model
- selected table.Row
- quitting bool
- showHelp bool
- hideCount bool
- help help.Model
- keymap keymap
- padding []int
+ table table.Model
+ selected table.Row
+ quitting bool
}
-func (m model) Init() tea.Cmd { return nil }
-
-func (m model) countView() string {
- if m.hideCount {
- return ""
- }
-
- padding := strconv.Itoa(numLen(len(m.table.Rows())))
- return m.help.Styles.FullDesc.Render(fmt.Sprintf(
- "%"+padding+"d/%d%s",
- m.table.Cursor()+1,
- len(m.table.Rows()),
- m.help.ShortSeparator,
- ))
+func (m model) Init() tea.Cmd {
+ return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -97,18 +34,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
- km := m.keymap
- switch {
- case key.Matches(msg, km.Select):
+ switch msg.String() {
+ case "enter":
m.selected = m.table.SelectedRow()
m.quitting = true
return m, tea.Quit
- case key.Matches(msg, km.Quit):
+ case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
- case key.Matches(msg, km.Abort):
- m.quitting = true
- return m, tea.Interrupt
}
}
@@ -120,23 +53,5 @@ func (m model) View() string {
if m.quitting {
return ""
}
- s := m.table.View()
- if m.showHelp {
- s += "\n" + m.countView() + m.help.View(m.keymap)
- }
- return lipgloss.NewStyle().
- Padding(m.padding...).
- Render(s)
-}
-
-func numLen(i int) int {
- if i == 0 {
- return 1
- }
- count := 0
- for i != 0 {
- i /= 10
- count++
- }
- return count
+ return m.table.View()
}
diff --git a/timeout/options.go b/timeout/options.go
new file mode 100644
index 0000000..9986835
--- /dev/null
+++ b/timeout/options.go
@@ -0,0 +1,55 @@
+package timeout
+
+import (
+ "fmt"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Tick interval.
+const tickInterval = time.Second
+
+// TickTimeoutMsg will be dispatched for every tick.
+// Containing current timeout value
+// and optional parameter to be used when handling the timeout msg.
+type TickTimeoutMsg struct {
+ TimeoutValue time.Duration
+ Data interface{}
+}
+
+// Init Start Timeout ticker using with timeout in seconds and optional data.
+func Init(timeout time.Duration, data interface{}) tea.Cmd {
+ if timeout > 0 {
+ return Tick(timeout, data)
+ }
+ return nil
+}
+
+// Start ticker.
+func Tick(timeoutValue time.Duration, data interface{}) tea.Cmd {
+ return tea.Tick(tickInterval, func(time.Time) tea.Msg {
+ // every tick checks if the timeout needs to be decremented
+ // and send as message
+ if timeoutValue >= 0 {
+ timeoutValue -= tickInterval
+ return TickTimeoutMsg{
+ TimeoutValue: timeoutValue,
+ Data: data,
+ }
+ }
+ return nil
+ })
+}
+
+// Str produce Timeout String to be rendered.
+func Str(timeout time.Duration) string {
+ return fmt.Sprintf(" (%d)", max(0, int(timeout.Seconds())))
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/version/command.go b/version/command.go
deleted file mode 100644
index f90177d..0000000
--- a/version/command.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// Package version the version command.
-package version
-
-import (
- "fmt"
-
- "github.com/Masterminds/semver/v3"
- "github.com/alecthomas/kong"
-)
-
-// Run check that a given version matches a semantic version constraint.
-func (o Options) Run(ctx *kong.Context) error {
- c, err := semver.NewConstraint(o.Constraint)
- if err != nil {
- return fmt.Errorf("could not parse range %s: %w", o.Constraint, err)
- }
- current := ctx.Model.Vars()["versionNumber"]
- v, err := semver.NewVersion(current)
- if err != nil {
- return fmt.Errorf("could not parse version %s: %w", current, err)
- }
- if !c.Check(v) {
- return fmt.Errorf("gum version %q is not within given range %q", current, o.Constraint)
- }
- return nil
-}
diff --git a/version/options.go b/version/options.go
deleted file mode 100644
index 2dbb19d..0000000
--- a/version/options.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package version
-
-// Options is the set of options that can be used with version.
-type Options struct {
- Constraint string `arg:"" help:"Semantic version constraint"`
-}
diff --git a/write/command.go b/write/command.go
index 6a745fb..d07e2e7 100644
--- a/write/command.go
+++ b/write/command.go
@@ -1,87 +1,54 @@
package write
import (
- "errors"
"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/stdin"
- "github.com/charmbracelet/gum/internal/timeout"
- "github.com/charmbracelet/gum/style"
+ "github.com/charmbracelet/huh"
)
// Run provides a shell script interface for the text area bubble.
// https://github.com/charmbracelet/bubbles/textarea
func (o Options) Run() error {
- in, _ := stdin.Read(stdin.StripANSI(o.StripANSI))
+ in, _ := stdin.Read()
if in != "" && o.Value == "" {
o.Value = strings.ReplaceAll(in, "\r", "")
}
- a := textarea.New()
- a.Focus()
+ var value = o.Value
- a.Prompt = o.Prompt
- a.Placeholder = o.Placeholder
- a.ShowLineNumbers = o.ShowLineNumbers
- a.CharLimit = o.CharLimit
- a.MaxHeight = o.MaxLines
- top, right, bottom, left := style.ParsePadding(o.Padding)
+ 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()
- 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(),
- }
+ keymap := huh.NewDefaultKeyMap()
+ keymap.Text.NewLine.SetHelp("ctrl+j", "new line")
- a.BlurredStyle = style
- a.FocusedStyle = style
- a.Cursor.Style = o.CursorStyle.ToLipgloss()
- a.Cursor.SetMode(cursor.Modes[o.CursorMode])
+ 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.SetWidth(max(0, o.Width-left-right))
- a.SetHeight(max(0, o.Height-top-bottom))
- a.SetValue(o.Value)
-
- m := model{
- textarea: a,
- header: o.Header,
- headerStyle: o.HeaderStyle.ToLipgloss(),
- autoWidth: o.Width < 1,
- help: help.New(),
- showHelp: o.ShowHelp,
- keymap: defaultKeymap(),
- padding: []int{top, right, bottom, left},
- }
-
- m.textarea.KeyMap.InsertNewline = m.keymap.InsertNewline
-
- ctx, cancel := timeout.Context(o.Timeout)
- defer cancel()
-
- p := tea.NewProgram(
- m,
- tea.WithOutput(os.Stderr),
- tea.WithReportFocus(),
- tea.WithContext(ctx),
- )
- tm, err := p.Run()
if err != nil {
- return fmt.Errorf("failed to run write: %w", err)
+ return err
}
- m = tm.(model)
- if !m.submitted {
- return errors.New("not submitted")
- }
- fmt.Println(m.textarea.Value())
+
+ fmt.Println(value)
return nil
}
diff --git a/write/options.go b/write/options.go
index 63c7b0c..109c3dc 100644
--- a/write/options.go
+++ b/write/options.go
@@ -1,36 +1,29 @@
package write
-import (
- "time"
-
- "github.com/charmbracelet/gum/style"
-)
+import "github.com/charmbracelet/gum/style"
// Options are the customization options for the textarea.
type Options struct {
- Width int `help:"Text area width (0 for terminal width)" default:"0" env:"GUM_WRITE_WIDTH"`
- Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"`
- Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"`
- Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"`
- Prompt string `help:"Prompt to display" default:"┃ " env:"GUM_WRITE_PROMPT"`
- ShowCursorLine bool `help:"Show cursor line" default:"false" env:"GUM_WRITE_SHOW_CURSOR_LINE"`
- ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"`
- Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"`
- CharLimit int `help:"Maximum value length (0 for no limit)" default:"0"`
- MaxLines int `help:"Maximum number of lines (0 for no limit)" default:"0"`
- 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"`
- Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_WRITE_TIMEOUT"`
- StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_WRITE_STRIP_ANSI"`
+ Width int `help:"Text area width (0 for terminal width)" default:"50" env:"GUM_WRITE_WIDTH"`
+ Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"`
+ Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"`
+ Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"`
+ Prompt string `help:"Prompt to display" default:"┃ " env:"GUM_WRITE_PROMPT"`
+ ShowCursorLine bool `help:"Show cursor line" default:"false" env:"GUM_WRITE_SHOW_CURSOR_LINE"`
+ ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"`
+ Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"`
+ CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
+ 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_"`
- 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_"`
- Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_WRITE_PADDING"`
+ 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_"`
}
diff --git a/write/write.go b/write/write.go
deleted file mode 100644
index b7e65a3..0000000
--- a/write/write.go
+++ /dev/null
@@ -1,202 +0,0 @@
-// 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
- Abort 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("esc"),
- key.WithHelp("esc", "quit"),
- ),
- Abort: 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
- header string
- headerStyle lipgloss.Style
- quitting bool
- submitted bool
- textarea textarea.Model
- showHelp bool
- help help.Model
- keymap keymap
- padding []int
-}
-
-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.NewStyle().
- Padding(m.padding...).
- Render(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 - m.padding[1] - m.padding[3])
- }
- 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.quitting = true
- return m, tea.Interrupt
- }
- m.textarea.SetValue(msg.content)
- case tea.KeyMsg:
- km := m.keymap
- switch {
- case key.Matches(msg, km.Abort):
- m.quitting = true
- return m, tea.Interrupt
- case key.Matches(msg, km.Quit):
- m.quitting = true
- return m, tea.Quit
- case key.Matches(msg, km.Submit):
- m.quitting = true
- m.submitted = true
- return m, tea.Quit
- case key.Matches(msg, km.OpenInEditor):
- return m, createTempFile(m.textarea.Value(), m.textarea.Line()+1)
- }
- }
-
- var cmd tea.Cmd
- m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
-}
-
-type startEditorMsg struct {
- path string
- lineno int
-}
-
-type editorFinishedMsg struct {
- content string
- err error
-}
-
-func createTempFile(content string, lineno int) 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 int) 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)
-}