diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 60f9b47..cd9f34c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @maaslalani +* @charmbracelet/everyone diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea7..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -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 deleted file mode 100644 index bbcbbe7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -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 9fc3b07..d944991 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,57 @@ version: 2 + updates: - package-ecosystem: "gomod" directory: "/" schedule: - interval: "daily" - labels: - - "dependencies" - commit-message: - prefix: "feat" - include: "scope" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" 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" + 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 5a74cbb..2330819 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,34 +1,13 @@ name: build -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: jobs: build: - 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 + uses: charmbracelet/meta/.github/workflows/build.yml@main secrets: - goreleaser_key: ${{ secrets.GORELEASER_KEY }} + gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml new file mode 100644 index 0000000..9b08259 --- /dev/null +++ b/.github/workflows/dependabot-sync.yml @@ -0,0 +1,17 @@ +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 deleted file mode 100644 index 87d1e1f..0000000 --- a/.github/workflows/lint-soft.yml +++ /dev/null @@ -1,28 +0,0 @@ -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 new file mode 100644 index 0000000..ecf8580 --- /dev/null +++ b/.github/workflows/lint-sync.yml @@ -0,0 +1,14 @@ +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 f617a5a..a1d6d0e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,26 +3,6 @@ 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 - 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 + lint: + uses: charmbracelet/meta/.github/workflows/lint.yml@main diff --git a/.golangci-soft.yml b/.golangci-soft.yml deleted file mode 100644 index 01d7797..0000000 --- a/.golangci-soft.yml +++ /dev/null @@ -1,46 +0,0 @@ -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 a5a91d0..c90f031 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,25 +1,22 @@ +version: "2" run: tests: false - -issues: - include: - - EXC0001 - - EXC0005 - - EXC0011 - - EXC0012 - - EXC0013 - - max-issues-per-linter: 0 - max-same-issues: 0 - linters: enable: - bodyclose - - exportloopref - - goimports + - exhaustive + - goconst + - godot + - gomoddirectives + - goprintffuncname - gosec + - misspell + - nakedret + - nestif - nilerr - - predeclared + - noctx + - nolintlint + - prealloc - revive - rowserrcheck - sqlclosecheck @@ -27,3 +24,24 @@ 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 7bad023..b478db6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,7 @@ # 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 120ea16..80a2b67 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Gum -=== +# Gum

Gum Image @@ -21,11 +20,13 @@ 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 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) +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) for your dotfiles. Ask for the commit type with gum choose: + ```bash gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert" ``` @@ -34,17 +35,20 @@ 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" ``` @@ -64,6 +68,9 @@ brew install gum # Arch Linux (btw) pacman -S gum +# Fedora or EPEL 10 +dnf install gum + # Nix nix-env -iA nixpkgs.gum @@ -84,10 +91,11 @@ 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 +Fedora/RHEL/OpenSuse ```bash echo '[charm] @@ -96,14 +104,35 @@ 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 +``` +
Or download it: -* [Packages][releases] are available in Debian, RPM, and Alpine formats -* [Binaries][releases] are available for Linux, macOS, Windows, FreeBSD, OpenBSD, and NetBSD +- [Packages][releases] are available in Debian, RPM, and Alpine formats +- [Binaries][releases] are available for Linux, macOS, Windows, FreeBSD, OpenBSD, and NetBSD Or just install it with `go`: @@ -115,20 +144,19 @@ go install github.com/charmbracelet/gum@latest ## Commands - * [`choose`](#choose): Choose an option from a list of choices - * [`confirm`](#confirm): Ask a user to confirm an action - * [`file`](#file): Pick a file from a folder - * [`filter`](#filter): Filter items from a list - * [`format`](#format): Format a string using a template - * [`input`](#input): Prompt for some input - * [`join`](#join): Join text vertically or horizontally - * [`pager`](#pager): Scroll through a file - * [`spin`](#spin): Display spinner while running a command - * [`style`](#style): Apply coloring, borders, spacing to text - * [`table`](#table): Render a table of data - * [`write`](#write): Prompt for long-form text - * [`log`](#log): Log messages to output - +- [`choose`](#choose): Choose an option from a list of choices +- [`confirm`](#confirm): Ask a user to confirm an action +- [`file`](#file): Pick a file from a folder +- [`filter`](#filter): Filter items from a list +- [`format`](#format): Format a string using a template +- [`input`](#input): Prompt for some input +- [`join`](#join): Join text vertically or horizontally +- [`pager`](#pager): Scroll through a file +- [`spin`](#spin): Display spinner while running a command +- [`style`](#style): Apply coloring, borders, spacing to text +- [`table`](#table): Render a table of data +- [`write`](#write): Prompt for long-form text +- [`log`](#log): Log messages to output ## Customization @@ -136,6 +164,7 @@ You can customize `gum` options and styles with `--flags` and `$ENVIRONMENT_VARI See `gum --help` for a full view of each command's customization and configuration options. Customize with `--flags`: + ```bash gum input --cursor.foreground "#FF0" \ @@ -238,7 +267,7 @@ gum confirm && rm file.txt || echo "File not removed" Prompt the user to select a file from the file tree. ```bash -EDITOR $(gum file $HOME) +$EDITOR $(gum file $HOME) ``` Shell running gum file @@ -369,81 +398,87 @@ 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 new file mode 100644 index 0000000..c6be614 --- /dev/null +++ b/choose/choose.go @@ -0,0 +1,289 @@ +// 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 89c25bd..70b8a9f 100644 --- a/choose/command.go +++ b/choose/command.go @@ -4,132 +4,179 @@ import ( "errors" "fmt" "os" + "slices" + "sort" "strings" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/term" - + "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/lipgloss" ) -const widthBuffer = 2 - // Run provides a shell script interface for choosing between different through // options. func (o Options) Run() error { - if len(o.Options) <= 0 { - input, _ := stdin.Read() + 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 input == "" { return errors.New("no options provided, see `gum choose --help`") } - o.Options = strings.Split(input, "\n") + 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 } if o.SelectIfOne && len(o.Options) == 1 { - fmt.Println(o.Options[0]) + fmt.Println(options[o.Options[0]]) return nil } - theme := huh.ThemeCharm() - options := huh.NewOptions(o.Options...) - - theme.Focused.Base = lipgloss.NewStyle() - theme.Focused.Title = o.HeaderStyle.ToLipgloss() - theme.Focused.SelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor) - theme.Focused.MultiSelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor) - theme.Focused.SelectedOption = o.SelectedItemStyle.ToLipgloss() - theme.Focused.UnselectedOption = o.ItemStyle.ToLipgloss() - theme.Focused.SelectedPrefix = o.SelectedItemStyle.ToLipgloss().SetString(o.SelectedPrefix) - theme.Focused.UnselectedPrefix = o.ItemStyle.ToLipgloss().SetString(o.UnselectedPrefix) - - for _, s := range o.Selected { - for i, opt := range options { - if s == opt.Key || s == opt.Value { - options[i] = opt.Selected(true) - } - } + // 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 = "" } if o.NoLimit { - o.Limit = len(o.Options) + o.Limit = len(o.Options) + 1 } - width := max(widest(o.Options)+ - max(lipgloss.Width(o.SelectedPrefix)+lipgloss.Width(o.UnselectedPrefix))+ - lipgloss.Width(o.Cursor)+1, lipgloss.Width(o.Header)+widthBuffer) - - if o.Limit > 1 { - var choices []string - - field := huh.NewMultiSelect[string](). - Options(options...). - Title(o.Header). - Height(o.Height). - Limit(o.Limit). - Value(&choices) - - form := huh.NewForm(huh.NewGroup(field)) - - err := form. - WithWidth(width). - WithShowHelp(o.ShowHelp). - WithTheme(theme). - Run() - if err != nil { - return err - } - if len(choices) > 0 { - s := strings.Join(choices, "\n") - ansiprint(s) - } - return nil + if o.Ordered { + slices.SortFunc(o.Options, strings.Compare) } - var choice string + isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*" - 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() + // 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++ + } + } + 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) + } + + 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, + } + + ctx, cancel := timeout.Context(o.Timeout) + defer cancel() + + // 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 err + 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 + }) } - if term.IsTerminal(os.Stdout.Fd()) { - fmt.Println(choice) - } else { - fmt.Print(ansi.Strip(choice)) + var out []string + for _, item := range m.items { + if item.selected { + out = append(out, options[item.text]) + } } - + tty.Println(strings.Join(out, o.OutputDelimiter)) 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 c9f07bd..abfca22 100644 --- a/choose/options.go +++ b/choose/options.go @@ -8,22 +8,28 @@ 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:"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,...] + 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_"` } diff --git a/completion/bash.go b/completion/bash.go index 3064ec1..33d309b 100644 --- a/completion/bash.go +++ b/completion/bash.go @@ -1,3 +1,5 @@ +// Package completion provides a bash completion generator for Kong +// applications. package completion import ( @@ -628,6 +630,7 @@ 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 8335356..9fc80d2 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 30fd51d..fc0a02b 100644 --- a/confirm/command.go +++ b/confirm/command.go @@ -1,42 +1,71 @@ package confirm import ( + "context" "fmt" "os" - "github.com/charmbracelet/huh" + "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" ) // Run provides a shell script interface for prompting a user to confirm an // action with an affirmative or negative answer. func (o Options) Run() error { - theme := huh.ThemeCharm() - theme.Focused.Title = o.PromptStyle.ToLipgloss() - theme.Focused.FocusedButton = o.SelectedStyle.ToLipgloss() - theme.Focused.BlurredButton = o.UnselectedStyle.ToLipgloss() - - choice := o.Default - - err := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Affirmative(o.Affirmative). - Negative(o.Negative). - Title(o.Prompt). - Value(&choice), - ), - ). - WithTheme(theme). - WithShowHelp(o.ShowHelp). - Run() - - if err != nil { - return fmt.Errorf("unable to run confirm: %w", err) + line, err := stdin.Read(stdin.SingleLine(true)) + if err == nil { + switch line { + case "yes", "y": + return nil + default: + return exit.ErrExit(1) + } } - if !choice { - os.Exit(1) + 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) } - return nil + if m.confirmation { + return nil + } + + return exit.ErrExit(1) } diff --git a/confirm/confirm.go b/confirm/confirm.go new file mode 100644 index 0000000..ac35c39 --- /dev/null +++ b/confirm/confirm.go @@ -0,0 +1,168 @@ +// 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 f604816..9740885 100644 --- a/confirm/options.go +++ b/confirm/options.go @@ -9,6 +9,7 @@ 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?"` @@ -19,5 +20,6 @@ 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:"0" env:"GUM_CONFIRM_TIMEOUT"` + 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"` } diff --git a/cursor/cursor.go b/cursor/cursor.go index aa49c05..3c49849 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,3 +1,4 @@ +// Package cursor provides cursor modes. package cursor import ( diff --git a/default.nix b/default.nix index b0cdedf..511568f 100644 --- a/default.nix +++ b/default.nix @@ -2,11 +2,11 @@ pkgs.buildGoModule rec { pname = "gum"; - version = "0.14.0"; + version = "0.15.2"; src = ./.; - vendorHash = "sha256-gDDaKrwlrJyyDzgyGf9iP/XPnOAwpkvIyzCXobXrlF4="; + vendorHash = "sha256-TK2Fc4bTkiSpyYrg4dJOzamEnii03P7kyHZdah9izqY="; ldflags = [ "-s" "-w" "-X=main.Version=${version}" ]; } diff --git a/file/command.go b/file/command.go index f60ed29..b7cc546 100644 --- a/file/command.go +++ b/file/command.go @@ -3,10 +3,14 @@ package file import ( "errors" "fmt" + "os" "path/filepath" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "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" ) // Run is the interface to picking a file. @@ -24,40 +28,52 @@ func (o Options) Run() error { return fmt.Errorf("file not found: %w", err) } - theme := huh.ThemeCharm() - theme.Focused.Base = lipgloss.NewStyle() - theme.Focused.File = o.FileStyle.ToLipgloss() - theme.Focused.Directory = o.DirectoryStyle.ToLipgloss() - theme.Focused.SelectedOption = o.SelectedStyle.ToLipgloss() - - keymap := huh.NewDefaultKeyMap() - keymap.FilePicker.Open.SetEnabled(false) - - // XXX: These should be file selected specific. - theme.Focused.TextInput.Placeholder = o.PermissionsStyle.ToLipgloss() - theme.Focused.TextInput.Prompt = o.CursorStyle.ToLipgloss() - - err = huh.NewForm( - huh.NewGroup( - huh.NewFilePicker(). - Picking(true). - CurrentDirectory(path). - DirAllowed(o.Directory). - FileAllowed(o.File). - Height(o.Height). - ShowHidden(o.All). - Value(&path), - ), - ). - WithShowHelp(o.ShowHelp). - WithKeyMap(keymap). - WithTheme(theme). - Run() - - if err != nil { - return 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, } - fmt.Println(path) + ctx, cancel := timeout.Context(o.Timeout) + defer cancel() + + 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") + } + + fmt.Println(m.selectedPath) return nil } diff --git a/file/file.go b/file/file.go new file mode 100644 index 0000000..33c8233 --- /dev/null +++ b/file/file.go @@ -0,0 +1,119 @@ +// 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 4b8e88d..61c9ced 100644 --- a/file/options.go +++ b/file/options.go @@ -11,21 +11,24 @@ 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"` - 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"` + 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"` - 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_"` - //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"` + 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"` } diff --git a/filter/command.go b/filter/command.go index 6ae7b28..6c9b2a3 100644 --- a/filter/command.go +++ b/filter/command.go @@ -4,18 +4,20 @@ 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 @@ -33,8 +35,8 @@ func (o Options) Run() error { v := viewport.New(o.Width, o.Height) if len(o.Options) == 0 { - if input, _ := stdin.Read(); input != "" { - o.Options = strings.Split(input, "\n") + if input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); input != "" { + o.Options = strings.Split(input, o.InputDelimiter) } else { o.Options = files.List() } @@ -44,12 +46,14 @@ func (o Options) Run() error { return errors.New("no options provided, see `gum filter --help`") } - if o.SelectIfOne && len(o.Options) == 1 { - fmt.Println(o.Options[0]) - return nil - } + ctx, cancel := timeout.Context(o.Timeout) + defer cancel() - options := []tea.ProgramOption{tea.WithOutput(os.Stderr)} + options := []tea.ProgramOption{ + tea.WithOutput(os.Stderr), + tea.WithReportFocus(), + tea.WithContext(ctx), + } if o.Height == 0 { options = append(options, tea.WithAltScreen()) } @@ -58,21 +62,43 @@ 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, o.Options) + matches = fuzzy.Find(o.Value, filteringChoices) case o.Value != "" && !o.Fuzzy: - matches = exactMatches(o.Value, o.Options) + matches = exactMatches(o.Value, filteringChoices) default: - matches = matchAll(o.Options) + matches = matchAll(filteringChoices) } if o.NoLimit { o.Limit = len(o.Options) } - p := tea.NewProgram(model{ - choices: 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, indicator: o.Indicator, matches: matches, header: o.Header, @@ -88,51 +114,61 @@ 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, - timeout: o.Timeout, - hasTimeout: o.Timeout > 0, - sort: o.Sort, - }, options...) + sort: o.Sort && o.FuzzySort, + strict: o.Strict, + showHelp: o.ShowHelp, + keymap: km, + help: help.New(), + } - tm, err := p.Run() + 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() if err != nil { return fmt.Errorf("unable to run filter: %w", err) } - m := tm.(model) - if m.aborted { - return exit.ErrAborted - } - isTTY := term.IsTerminal(os.Stdout.Fd()) + m = tm.(model) + if !m.submitted { + return errors.New("nothing selected") + } // 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, isTTY) + o.checkSelected(m) } else if len(m.matches) > m.cursor && m.cursor >= 0 { - if isTTY { - fmt.Println(m.matches[m.cursor].Str) - } else { - fmt.Println(ansi.Strip(m.matches[m.cursor].Str)) - } + tty.Println(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, isTTY bool) { +func (o Options) checkSelected(m model) { + out := []string{} for k := range m.selected { - if isTTY { - fmt.Println(k) - } else { - fmt.Println(ansi.Strip(k)) - } + out = append(out, k) } + tty.Println(strings.Join(out, o.OutputDelimiter)) } diff --git a/filter/filter.go b/filter/filter.go index 38c25f9..5e433cd 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -12,21 +12,122 @@ 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 []string + choices map[string]string + filteringChoices []string matches []fuzzy.Match cursor int header string @@ -37,7 +138,7 @@ type model struct { selectedPrefix string unselectedPrefix string height int - aborted bool + padding []int quitting bool headerStyle lipgloss.Style matchStyle lipgloss.Style @@ -49,13 +150,15 @@ type model struct { reverse bool fuzzy bool sort bool - timeout time.Duration - hasTimeout bool + showHelp bool + keymap keymap + help help.Model + strict bool + submitted bool } -func (m model) Init() tea.Cmd { - return timeout.Init(m.timeout, nil) -} +func (m model) Init() tea.Cmd { return textinput.Blink } + func (m model) View() string { if m.quitting { return "" @@ -102,30 +205,24 @@ func (m model) View() string { s.WriteString(" ") } - // 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() - - 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) - } + 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 } - // Flush text buffer. - s.WriteString(lineTextStyle.Render(buf.String())) + + 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(lineTextStyle.Render(lipgloss.StyleRanges(styledOption, ranges...))) // We have finished displaying the match with all of it's matched // characters highlighted and the rest filled in. @@ -138,79 +235,114 @@ 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() + "\n" + m.textinput.View() + view := m.viewport.View() if m.header != "" { - return lipgloss.JoinVertical(lipgloss.Left, view, header) + view += "\n" + header } - - return view + view += "\n" + m.textinput.View() + if m.showHelp { + view += m.helpView() + } + return lipgloss.NewStyle(). + Padding(m.padding...). + Render(view) } view := m.textinput.View() + "\n" + m.viewport.View() - if m.header != "" { - return lipgloss.JoinVertical(lipgloss.Left, header, view) + if m.showHelp { + view += m.helpView() } - return view + if m.header != "" { + return lipgloss.NewStyle(). + Padding(m.padding...). + Render(header + "\n" + view) + } + + return lipgloss.NewStyle(). + Padding(m.padding...). + Render(view) +} + +func (m model) helpView() string { + return "\n\n" + m.help.View(m.keymap) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd + var cmd, icmd tea.Cmd + m.textinput, icmd = m.textinput.Update(msg) 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()) } - - // Make place in the view port if header is set + // Include the header in the height calculation. if m.header != "" { m.viewport.Height = m.viewport.Height - lipgloss.Height(m.headerStyle.Render(m.header)) } - m.viewport.Width = msg.Width + // 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] if m.reverse { - m.viewport.YOffset = clamp(0, len(m.matches), len(m.matches)-m.viewport.Height) + m.viewport.YOffset = ordered.Clamp(len(m.matches)-m.viewport.Height, 0, len(m.matches)) } case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - m.aborted = true + 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): m.quitting = true return m, tea.Quit - case "enter": + case key.Matches(msg, km.Abort): m.quitting = true + return m, tea.Interrupt + case key.Matches(msg, km.Submit): + m.quitting = true + m.submitted = true return m, tea.Quit - case "ctrl+n", "ctrl+j", "down": + case key.Matches(msg, km.Down, km.NDown): m.CursorDown() - case "ctrl+p", "ctrl+k", "up": + case key.Matches(msg, km.Up, km.NUp): m.CursorUp() - case "tab": + 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): if m.limit == 1 { break // no op } m.ToggleSelection() m.CursorDown() - case "shift+tab": + case key.Matches(msg, km.ToggleAndPrevious): if m.limit == 1 { break // no op } m.ToggleSelection() m.CursorUp() - case "ctrl+@": + case key.Matches(msg, km.Toggle): if m.limit == 1 { break // no op } 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 @@ -222,61 +354,91 @@ 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(), m.choices) + m.matches = fuzzy.Find(m.textinput.Value(), choices) } else { - m.matches = fuzzy.FindNoSort(m.textinput.Value(), m.choices) + m.matches = fuzzy.FindNoSort(m.textinput.Value(), choices) } } else { - m.matches = exactMatches(m.textinput.Value(), m.choices) + m.matches = exactMatches(m.textinput.Value(), 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.choices) + m.matches = matchAll(m.filteringChoices) } // 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 = clamp(0, maxYOffset, len(m.matches)-yOffsetFromBottom) + m.viewport.YOffset = ordered.Clamp(len(m.matches)-yOffsetFromBottom, 0, maxYOffset) } } } + 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 = clamp(0, len(m.matches)-1, m.cursor) - return m, cmd + m.cursor = ordered.Clamp(m.cursor, 0, len(m.matches)-1) + return m, tea.Batch(cmd, icmd) } func (m *model) CursorUp() { - if m.reverse { - m.cursor = clamp(0, len(m.matches)-1, m.cursor+1) + if len(m.matches) == 0 { + return + } + if m.reverse { //nolint:nestif + m.cursor = (m.cursor + 1) % len(m.matches) if len(m.matches)-m.cursor <= m.viewport.YOffset { - m.viewport.SetYOffset(len(m.matches) - m.cursor - 1) + 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) } } else { - m.cursor = clamp(0, len(m.matches)-1, m.cursor-1) + m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches) if m.cursor < m.viewport.YOffset { - m.viewport.SetYOffset(m.cursor) + m.viewport.ScrollUp(1) + } + if m.cursor >= m.viewport.YOffset+m.viewport.Height { + m.viewport.SetYOffset(len(m.matches) - m.viewport.Height) } } } func (m *model) CursorDown() { - if m.reverse { - m.cursor = clamp(0, len(m.matches)-1, m.cursor-1) + if len(m.matches) == 0 { + return + } + if m.reverse { //nolint:nestif + m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches) if len(m.matches)-m.cursor > m.viewport.Height+m.viewport.YOffset { - m.viewport.LineDown(1) + m.viewport.ScrollDown(1) + } + if len(m.matches)-m.cursor <= m.viewport.YOffset { + m.viewport.GotoTop() } } else { - m.cursor = clamp(0, len(m.matches)-1, m.cursor+1) + m.cursor = (m.cursor + 1) % len(m.matches) if m.cursor >= m.viewport.YOffset+m.viewport.Height { - m.viewport.LineDown(1) + m.viewport.ScrollDown(1) + } + if m.cursor < m.viewport.YOffset { + m.viewport.GotoTop() } } } @@ -291,6 +453,26 @@ 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 { @@ -322,20 +504,46 @@ func exactMatches(search string, choices []string) []fuzzy.Match { return matches } -//nolint:unparam -func clamp(min, max, val int) int { - if val < min { - return min +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} } - if val > max { - return max + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} } - return val + 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 } -func max(a, b int) int { - if a > b { - return a +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()) } - return b + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop } diff --git a/filter/filter_test.go b/filter/filter_test.go new file mode 100644 index 0000000..5840002 --- /dev/null +++ b/filter/filter_test.go @@ -0,0 +1,59 @@ +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 489c734..26eb3ea 100644 --- a/filter/options.go +++ b/filter/options.go @@ -15,12 +15,14 @@ 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"` - Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" default:"true" 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"` SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"` SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"` UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"` UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"` - HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_FILTER_HEADER_"` + HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILTER_HEADER_"` Header string `help:"Header value" default:"" env:"GUM_FILTER_HEADER"` TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"` CursorTextStyle style.Styles `embed:"" prefix:"cursor-text." envprefix:"GUM_FILTER_CURSOR_TEXT_"` @@ -29,11 +31,18 @@ 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:"20" env:"GUM_FILTER_WIDTH"` + Width int `help:"Input width" default:"0" 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" 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"` + 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:""` } diff --git a/flake.lock b/flake.lock index 7ceb95c..2537edd 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1715447595, - "narHash": "sha256-VsVAUQOj/cS1LCOmMjAGeRksXIAdPnFIjCQ0XLkCsT0=", + "lastModified": 1737062831, + "narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "062ca2a9370a27a35c524dc82d540e6e9824b652", + "rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c", "type": "github" }, "original": { diff --git a/format/README.md b/format/README.md index 288f234..3bf68d0 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) -3. [Emoji](#emoji) +4. [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,5 +55,30 @@ 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 82ebf0c..3217f0e 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() + input, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) } switch o.Type { diff --git a/format/options.go b/format/options.go index 6f36dcd..ee87f95 100644 --- a/format/options.go +++ b/format/options.go @@ -6,5 +6,7 @@ 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 f720d71..55e2352 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,26 @@ module github.com/charmbracelet/gum -go 1.21 +go 1.24.2 require ( - github.com/alecthomas/kong v0.9.0 + github.com/Masterminds/semver/v3 v3.4.0 + github.com/alecthomas/kong v1.14.0 github.com/alecthomas/mango-kong v0.1.0 - 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/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/muesli/roff v0.1.0 - github.com/muesli/termenv v0.15.2 + github.com/muesli/termenv v0.16.0 + github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 + golang.org/x/text v0.34.0 ) require ( @@ -24,32 +28,35 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/input v0.1.3 // indirect - github.com/charmbracelet/x/windows v0.1.2 // 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/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.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.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.16 // indirect - github.com/microcosm-cc/bluemonday v1.0.26 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // 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/olekukonko/tablewriter v0.0.5 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.2 // indirect - github.com/yuin/goldmark-emoji v1.0.2 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // 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 + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 4c66da0..c3d39b7 100644 --- a/go.sum +++ b/go.sum @@ -1,45 +1,67 @@ -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/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/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= -github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= +github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= 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.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +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/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/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/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/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= @@ -56,20 +78,17 @@ 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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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/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.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/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/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= @@ -80,10 +99,8 @@ 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.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/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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= @@ -92,26 +109,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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/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.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= +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= 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.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/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 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.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= +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= 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 2808c76..4191314 100644 --- a/gum.go +++ b/gum.go @@ -17,6 +17,7 @@ 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" ) @@ -133,7 +134,7 @@ type Gum struct { // │ 7 │ │ // │ 8 │ │ // ╰────────────────────────────────────────────────╯ - // ↑/↓: Navigate • q: Quit + // ↓↑: navigate • q: quit // Pager pager.Options `cmd:"" help:"Scroll through a file"` @@ -214,4 +215,14 @@ 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 57fc8a8..0900d8d 100644 --- a/input/command.go +++ b/input/command.go @@ -1,70 +1,79 @@ package input import ( + "errors" "fmt" - "os" - "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - + "github.com/charmbracelet/gum/cursor" "github.com/charmbracelet/gum/internal/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 { - var value string - if o.Value != "" { - value = o.Value - } else if in, _ := stdin.Read(); in != "" { - value = in + if o.Value == "" { + if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" { + o.Value = in + } } - 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 + 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 if o.Password { - echoMode = huh.EchoModePassword - } else { - echoMode = huh.EchoModeNormal + i.EchoMode = textinput.EchoPassword + i.EchoCharacter = '•' } - err := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Prompt(o.Prompt). - Placeholder(o.Placeholder). - CharLimit(o.CharLimit). - EchoMode(echoMode). - Title(o.Header). - Value(&value), - ), - ). - WithShowHelp(false). - WithWidth(o.Width). - WithTheme(theme). - WithKeyMap(keymap). - WithShowHelp(o.ShowHelp). - WithProgramOptions(tea.WithOutput(os.Stderr)). - Run() + 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(), + } + 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 err + return fmt.Errorf("failed to run input: %w", err) } - fmt.Println(value) + m = tm.(model) + if !m.submitted { + return errors.New("not submitted") + } + fmt.Println(m.textinput.Value()) return nil } diff --git a/input/input.go b/input/input.go new file mode 100644 index 0000000..f63ac6a --- /dev/null +++ b/input/input.go @@ -0,0 +1,100 @@ +// 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 7c68889..57cbf53 100644 --- a/input/options.go +++ b/input/options.go @@ -16,10 +16,12 @@ 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:"40" env:"GUM_INPUT_WIDTH"` + Width int `help:"Input width (0 for terminal width)" default:"0" env:"GUM_INPUT_WIDTH"` Password bool `help:"Mask input characters" default:"false"` - ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" env:"GUM_INPUT_SHOW_HELP"` + ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_INPUT_SHOW_HELP"` Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"` - Timeout time.Duration `help:"Timeout until input aborts" default:"0" env:"GUM_INPUT_TIMEOUT"` + 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"` } diff --git a/internal/decode/align.go b/internal/decode/align.go index 813bcdd..555f13c 100644 --- a/internal/decode/align.go +++ b/internal/decode/align.go @@ -1,3 +1,4 @@ +// 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 ff3a2b9..f523efc 100644 --- a/internal/exit/exit.go +++ b/internal/exit/exit.go @@ -1,9 +1,16 @@ +// Package exit code implementation. package exit -import "fmt" +import "strconv" + +// StatusTimeout is the exit code for timed out commands. +const StatusTimeout = 124 // StatusAborted is the exit code for aborted commands. const StatusAborted = 130 -// ErrAborted is the error to return when a gum command is aborted by Ctrl + C. -var ErrAborted = fmt.Errorf("aborted") +// ErrExit is a custom exit error. +type ErrExit int + +// Error implements error. +func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) } diff --git a/internal/files/files.go b/internal/files/files.go index 51e6950..d1cd19e 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -1,3 +1,4 @@ +// Package files handles files. package files import ( @@ -18,7 +19,6 @@ 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 deleted file mode 100644 index 2a9f9a9..0000000 --- a/internal/log/log.go +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index b28fedb..0000000 --- a/internal/stack/stack.go +++ /dev/null @@ -1,26 +0,0 @@ -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 1a1b0f3..2efdcfd 100644 --- a/internal/stdin/stdin.go +++ b/internal/stdin/stdin.go @@ -1,3 +1,4 @@ +// Package stdin handles processing input from stdin. package stdin import ( @@ -6,18 +7,58 @@ 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() (string, error) { +func Read(opts ...Option) (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 - for { + 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 { r, _, err := reader.ReadRune() if err != nil && err == io.EOF { break @@ -28,7 +69,11 @@ func Read() (string, error) { } } - return strings.TrimSuffix(b.String(), "\n"), nil + s := strings.TrimSpace(b.String()) + if options.ansiStrip { + return ansi.Strip(s), nil + } + return s, nil } // IsEmpty returns whether stdin is empty. diff --git a/internal/timeout/context.go b/internal/timeout/context.go new file mode 100644 index 0000000..ffd39e9 --- /dev/null +++ b/internal/timeout/context.go @@ -0,0 +1,16 @@ +// 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 new file mode 100644 index 0000000..75b8237 --- /dev/null +++ b/internal/tty/tty.go @@ -0,0 +1,24 @@ +// 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 deleted file mode 100644 index 0e38598..0000000 --- a/internal/utils/utils.go +++ /dev/null @@ -1,15 +0,0 @@ -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 7ea959e..1240083 100644 --- a/log/command.go +++ b/log/command.go @@ -1,3 +1,4 @@ +// Package log the log command. package log import ( @@ -16,7 +17,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) + f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm) //nolint:gosec if err != nil { return fmt.Errorf("error opening file: %w", err) } @@ -28,6 +29,13 @@ 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 9a19f36..12949e3 100644 --- a/log/options.go +++ b/log/options.go @@ -16,7 +16,9 @@ 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:""` - LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"` //nolint:staticcheck + 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_"` 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 b8f3407..a61916e 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// Package main is Gum: a tool for glamorous shell scripts. package main import ( @@ -7,11 +8,10 @@ import ( "runtime/debug" "github.com/alecthomas/kong" - "github.com/charmbracelet/huh" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/internal/exit" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" - - "github.com/charmbracelet/gum/internal/exit" ) const shaLen = 7 @@ -55,6 +55,7 @@ func main() { }), kong.Vars{ "version": version, + "versionNumber": Version, "defaultHeight": "0", "defaultWidth": "0", "defaultAlign": "left", @@ -73,10 +74,18 @@ func main() { }, ) if err := ctx.Run(); err != nil { - if errors.Is(err, exit.ErrAborted) || errors.Is(err, huh.ErrUserAborted) { + var ex exit.ErrExit + if errors.As(err, &ex) { + os.Exit(int(ex)) + } + if errors.Is(err, tea.ErrInterrupted) { os.Exit(exit.StatusAborted) } - fmt.Println(err) + if errors.Is(err, tea.ErrProgramKilled) { + fmt.Fprintln(os.Stderr, "timed out") + os.Exit(exit.StatusTimeout) + } + fmt.Fprintln(os.Stderr, err) os.Exit(1) } } diff --git a/man/command.go b/man/command.go index 1d83dab..22f5bec 100644 --- a/man/command.go +++ b/man/command.go @@ -1,3 +1,4 @@ +// Package man the man command. package man import ( diff --git a/pager/command.go b/pager/command.go index 5131b96..414bee7 100644 --- a/pager/command.go +++ b/pager/command.go @@ -4,9 +4,11 @@ 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. @@ -29,9 +31,9 @@ func (o Options) Run() error { } } - model := model{ + m := model{ viewport: vp, - helpStyle: o.HelpStyle.ToLipgloss(), + help: help.New(), content: o.Content, origContent: o.Content, showLineNumbers: o.ShowLineNumbers, @@ -39,12 +41,21 @@ func (o Options) Run() error { softWrap: o.SoftWrap, matchStyle: o.MatchStyle.ToLipgloss(), matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(), - timeout: o.Timeout, - hasTimeout: o.Timeout > 0, + keymap: defaultKeymap(), } - _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() + + ctx, cancel := timeout.Context(o.Timeout) + defer cancel() + + _, err := tea.NewProgram( + m, + tea.WithAltScreen(), + tea.WithReportFocus(), + tea.WithContext(ctx), + ).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 f257cb0..a44fca7 100644 --- a/pager/options.go +++ b/pager/options.go @@ -10,12 +10,14 @@ 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:"false"` + SoftWrap bool `help:"Soft wrap lines" default:"true" negatable:""` 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:"0" env:"GUM_PAGER_TIMEOUT"` + 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:""` } diff --git a/pager/pager.go b/pager/pager.go index ca24fc6..324a314 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -6,21 +6,93 @@ 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/muesli/reflow/truncate" + "github.com/charmbracelet/x/ansi" ) +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 - helpStyle lipgloss.Style + help help.Model showLineNumbers bool lineNumberStyle lipgloss.Style softWrap bool @@ -28,34 +100,33 @@ type model struct { matchStyle lipgloss.Style matchHighlightStyle lipgloss.Style maxWidth int - timeout time.Duration - hasTimeout bool + keymap keymap } -func (m model) Init() tea.Cmd { - return timeout.Init(m.timeout, nil) -} +func (m model) Init() tea.Cmd { return 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) } - return m, nil + 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 } -func (m *model) ProcessText(msg tea.WindowSizeMsg) { - m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1 +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()) m.viewport.Width = msg.Width textStyle := lipgloss.NewStyle().Width(m.viewport.Width) var text strings.Builder @@ -75,17 +146,21 @@ func (m *model) ProcessText(msg tea.WindowSizeMsg) { if m.showLineNumbers { text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1))) } - 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(" │ ")) + 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") } - line = strings.Replace(line, truncatedLine, "", 1) + } else { + text.WriteString(textStyle.Render(line)) + text.WriteString("\n") } - text.WriteString(textStyle.Render(truncate.String(line, uint(m.maxWidth)))) - text.WriteString("\n") } diffHeight := m.viewport.Height - lipgloss.Height(text.String()) @@ -98,62 +173,57 @@ func (m *model) ProcessText(msg tea.WindowSizeMsg) { const heightOffset = 2 -func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) { +func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) { + km := m.keymap var cmd tea.Cmd if m.search.active { - switch key.String() { - case "enter": + switch { + case key.Matches(msg, km.ConfirmSearch): 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 "ctrl+d", "ctrl+c", "esc": + case key.Matches(msg, km.CancelSearch): m.search.Done() default: - m.search.input, cmd = m.search.input.Update(key) + m.search.input, cmd = m.search.input.Update(msg) } } else { - switch key.String() { - case "g", "home": + switch { + case key.Matches(msg, km.Home): m.viewport.GotoTop() - case "G", "end": + case key.Matches(msg, km.End): m.viewport.GotoBottom() - case "/": + case key.Matches(msg, km.Search): m.search.Begin() - case "p", "N": + return m, textinput.Blink + case key.Matches(msg, km.PrevMatch): m.search.PrevMatch(&m) - m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) - case "n": + m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) + case key.Matches(msg, km.NextMatch): m.search.NextMatch(&m) - m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) - case "q", "ctrl+c", "esc": + m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) + case key.Matches(msg, km.Quit): return m, tea.Quit + case key.Matches(msg, km.Abort): + return m, tea.Interrupt } - m.viewport, cmd = m.viewport.Update(key) + m.viewport, cmd = m.viewport.Update(msg) } 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" + timeoutStr + " " + m.search.input.View() + return m.viewport.View() + "\n " + m.search.input.View() } - return m.viewport.View() + m.helpStyle.Render(helpMsg) + return m.viewport.View() + "\n" + m.helpView() } diff --git a/pager/search.go b/pager/search.go index b112d7d..134096c 100644 --- a/pager/search.go +++ b/pager/search.go @@ -6,9 +6,8 @@ import ( "strings" "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/gum/internal/utils" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" + "github.com/charmbracelet/x/ansi" ) type search struct { @@ -52,7 +51,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, _ := utils.LipglossPadding(m.matchStyle) + leftPad, _ := 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 { @@ -82,7 +81,7 @@ func (s *search) NextMatch(m *model) { return } - leftPad, rightPad := utils.LipglossPadding(m.matchStyle) + leftPad, rightPad := lipglossPadding(m.matchStyle) s.matchIndex = (s.matchIndex + 1) % len(allMatches) match := allMatches[s.matchIndex] lhs := m.content[:match[0]] @@ -125,7 +124,7 @@ func (s *search) PrevMatch(m *model) { s.matchIndex = len(allMatches) - 1 } - leftPad, rightPad := utils.LipglossPadding(m.matchStyle) + leftPad, rightPad := lipglossPadding(m.matchStyle) match := allMatches[s.matchIndex] lhs := m.content[:match[0]] rhs := m.content[match[0]:] @@ -150,15 +149,27 @@ 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") { - for softWrap && lipgloss.Width(line) > maxWidth { - truncatedLine := truncate.String(line, uint(maxWidth)) - text.WriteString(truncatedLine) + 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) 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 6bde58d..cff2797 100644 --- a/spin/command.go +++ b/spin/command.go @@ -6,64 +6,77 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/term" - "github.com/charmbracelet/gum/internal/exit" + "github.com/charmbracelet/gum/internal/timeout" + "github.com/charmbracelet/gum/style" + "github.com/charmbracelet/x/term" ) // Run provides a shell script interface for the spinner bubble. // https://github.com/charmbracelet/bubbles/spinner func (o Options) Run() error { - isTTY := term.IsTerminal(os.Stdout.Fd()) + isOutTTY := term.IsTerminal(os.Stdout.Fd()) + isErrTTY := term.IsTerminal(os.Stderr.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, - showOutput: o.ShowOutput && isTTY, + showStdout: (o.ShowOutput || o.ShowStdout) && isOutTTY, + showStderr: (o.ShowOutput || o.ShowStderr) && isErrTTY, showError: o.ShowError, - timeout: o.Timeout, - hasTimeout: o.Timeout > 0, + isTTY: isErrTTY, + padding: []int{top, right, bottom, left}, } - 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("failed to run spin: %w", err) - } - - if m.aborted { - return exit.ErrAborted + return fmt.Errorf("unable to run action: %w", err) } + 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.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) - } + 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) } } } 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. - _, err := os.Stdout.WriteString(m.output) - if err != nil { + if _, err := os.Stdout.WriteString(m.output); err != nil { return fmt.Errorf("failed to write to stdout: %w", err) } } - os.Exit(m.status) - return nil + return exit.ErrExit(m.status) } diff --git a/spin/options.go b/spin/options.go index 542ff2b..702cc2a 100644 --- a/spin/options.go +++ b/spin/options.go @@ -10,12 +10,15 @@ import ( type Options struct { Command []string `arg:"" help:"Command to run"` - ShowOutput bool `help:"Show or pipe output of command during execution" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"` + ShowOutput bool `help:"Show or pipe output of command during execution (shows both STDOUT and STDERR)" 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:"0" env:"GUM_SPIN_TIMEOUT"` + 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"` } diff --git a/spin/pty.go b/spin/pty.go new file mode 100644 index 0000000..9562434 --- /dev/null +++ b/spin/pty.go @@ -0,0 +1,22 @@ +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 db63fa4..6e41690 100644 --- a/spin/spin.go +++ b/spin/spin.go @@ -15,43 +15,49 @@ package spin import ( + "bytes" + "context" "io" "os" "os/exec" - "strings" - "time" - - "github.com/charmbracelet/gum/internal/exit" - "github.com/charmbracelet/gum/timeout" - "github.com/charmbracelet/x/term" + "runtime" + "syscall" "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 - aborted bool + isTTY bool status int stdout string stderr string output string - showOutput bool + showStdout bool + showStderr bool showError bool - timeout time.Duration - hasTimeout bool + err error } var ( - bothbuf strings.Builder - outbuf strings.Builder - errbuf strings.Builder + bothbuf bytes.Buffer + outbuf bytes.Buffer + errbuf bytes.Buffer + + executing *exec.Cmd ) +type errorMsg error + type finishCommandMsg struct { stdout string stderr string @@ -65,22 +71,54 @@ func commandStart(command []string) tea.Cmd { if len(command) > 1 { args = command[1:] } - cmd := exec.Command(command[0], args...) //nolint:gosec - if term.IsTerminal(os.Stdout.Fd()) { - stdout := io.MultiWriter(&bothbuf, &errbuf) - stderr := io.MultiWriter(&bothbuf, &outbuf) + executing = exec.CommandContext(context.Background(), command[0], args...) //nolint:gosec + executing.Stdin = os.Stdin - cmd.Stdout = stdout - cmd.Stderr = stderr + 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) } else { - cmd.Stdout = os.Stdout + executing.Stdout = os.Stdout + executing.Stderr = os.Stderr + _ = executing.Run() } - _ = cmd.Run() - - status := cmd.ProcessState.ExitCode() - + status := executing.ProcessState.ExitCode() if status == -1 { status = 1 } @@ -94,48 +132,50 @@ 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 && m.showOutput { - return strings.TrimPrefix(errbuf.String()+"\n"+outbuf.String(), "\n") + if m.quitting { + return "" } - var str string - if m.hasTimeout { - str = timeout.Str(m.timeout) + var out string + if m.showStderr { + out += errbuf.String() } + if m.showStdout { + out += outbuf.String() + } + + if !m.isTTY { + return m.title + } + var header string if m.align == "left" { - header = m.spinner.View() + str + " " + m.title + header = m.spinner.View() + " " + m.title } else { - header = str + " " + m.title + " " + m.spinner.View() + header = m.title + " " + m.spinner.View() } - if !m.showOutput { - return header - } - return header + errbuf.String() + "\n" + outbuf.String() + return lipgloss.NewStyle(). + Padding(m.padding...). + Render(header, "", out) } 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 @@ -146,11 +186,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": - m.aborted = true - return m, tea.Quit + return m, commandAbort } + 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 new file mode 100644 index 0000000..ba425ec --- /dev/null +++ b/style/ascii_a.txt @@ -0,0 +1,7 @@ + # + # # + # # +# # +####### +# # +# # diff --git a/style/command.go b/style/command.go index e698df9..0263211 100644 --- a/style/command.go +++ b/style/command.go @@ -20,11 +20,18 @@ func (o Options) Run() error { if len(o.Text) > 0 { text = strings.Join(o.Text, "\n") } else { - text, _ = stdin.Read() + text, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) 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 9ba52a2..07ad0c8 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 0b250c2..67545e5 100644 --- a/style/options.go +++ b/style/options.go @@ -2,8 +2,10 @@ 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"` - Style StylesNotHidden `embed:""` + 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:""` } // Styles is a flag set of possible styles. @@ -15,7 +17,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" hidden:"true"` + Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND"` // 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 6b3fe26..57ea7db 100644 --- a/style/spacing.go +++ b/style/spacing.go @@ -5,13 +5,15 @@ import ( "strings" ) -const minTokens = 1 -const halfTokens = 2 -const maxTokens = 4 +const ( + minTokens = 1 + halfTokens = 2 + 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, " ") @@ -46,4 +48,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 new file mode 100644 index 0000000..e492410 --- /dev/null +++ b/table/bom.csv @@ -0,0 +1,4 @@ +"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 1e08faf..479cd93 100644 --- a/table/command.go +++ b/table/command.go @@ -5,31 +5,40 @@ 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" - - "github.com/charmbracelet/gum/internal/stdin" - "github.com/charmbracelet/gum/style" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" ) // Run provides a shell script interface for rendering tabular data (CSV). func (o Options) Run() error { - var reader *csv.Reader + var input *os.File if o.File != "" { - file, err := os.Open(o.File) + var err error + input, err = os.Open(o.File) if err != nil { - return fmt.Errorf("could not find file at path %s", o.File) + return fmt.Errorf("could not render file: %w", err) } - reader = csv.NewReader(file) } else { if stdin.IsEmpty() { return fmt.Errorf("no data provided") } - reader = csv.NewReader(os.Stdin) + input = 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") @@ -70,6 +79,7 @@ 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()), @@ -78,11 +88,26 @@ func (o Options) Run() error { } rows := make([]table.Row, 0, len(data)) - for _, row := range data { - if len(row) > len(columns) { + for row := range data { + if len(data[row]) > len(columns) { return fmt.Errorf("invalid number of columns") } - rows = append(rows, table.Row(row)) + + // 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])) } if o.Print { @@ -102,15 +127,34 @@ func (o Options) Run() error { return nil } - table := table.New( + opts := []table.Option{ 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)) + } - tm, err := tea.NewProgram(model{table: table}, tea.WithOutput(os.Stderr)).Run() + 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() if err != nil { return fmt.Errorf("failed to start tea program: %w", err) } @@ -119,10 +163,15 @@ func (o Options) Run() error { return fmt.Errorf("failed to get selection") } - m := tm.(model) - - if err = writer.Write([]string(m.selected)); err != nil { - return fmt.Errorf("failed to write selected row: %w", err) + 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) + } } writer.Flush() diff --git a/table/options.go b/table/options.go index b70578c..d7a241f 100644 --- a/table/options.go +++ b/table/options.go @@ -1,19 +1,30 @@ package table -import "github.com/charmbracelet/gum/style" +import ( + "time" + + "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:"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"` + 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"` - 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_"` + 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"` } diff --git a/table/table.go b/table/table.go index d9a92ed..c0d389f 100644 --- a/table/table.go +++ b/table/table.go @@ -15,18 +15,81 @@ 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 model struct { - table table.Model - selected table.Row - quitting bool +type keymap struct { + Navigate, + Select, + Quit, + Abort key.Binding } -func (m model) Init() tea.Cmd { - return nil +// 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 +} + +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) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -34,14 +97,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "enter": + km := m.keymap + switch { + case key.Matches(msg, km.Select): m.selected = m.table.SelectedRow() m.quitting = true return m, tea.Quit - case "ctrl+c", "q", "esc": + 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 } } @@ -53,5 +120,23 @@ func (m model) View() string { if m.quitting { return "" } - return m.table.View() + 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 } diff --git a/timeout/options.go b/timeout/options.go deleted file mode 100644 index 9986835..0000000 --- a/timeout/options.go +++ /dev/null @@ -1,55 +0,0 @@ -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 new file mode 100644 index 0000000..f90177d --- /dev/null +++ b/version/command.go @@ -0,0 +1,26 @@ +// 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 new file mode 100644 index 0000000..2dbb19d --- /dev/null +++ b/version/options.go @@ -0,0 +1,6 @@ +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 d07e2e7..6a745fb 100644 --- a/write/command.go +++ b/write/command.go @@ -1,54 +1,87 @@ 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/huh" + "github.com/charmbracelet/gum/internal/timeout" + "github.com/charmbracelet/gum/style" ) // 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() + in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)) if in != "" && o.Value == "" { o.Value = strings.ReplaceAll(in, "\r", "") } - var value = o.Value + a := textarea.New() + a.Focus() - theme := huh.ThemeCharm() - theme.Focused.Base = o.BaseStyle.ToLipgloss() - theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss() - theme.Focused.Title = o.HeaderStyle.ToLipgloss() - theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss() - theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss() + a.Prompt = o.Prompt + a.Placeholder = o.Placeholder + a.ShowLineNumbers = o.ShowLineNumbers + a.CharLimit = o.CharLimit + a.MaxHeight = o.MaxLines + top, right, bottom, left := style.ParsePadding(o.Padding) - keymap := huh.NewDefaultKeyMap() - keymap.Text.NewLine.SetHelp("ctrl+j", "new line") - - err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title(o.Header). - Placeholder(o.Placeholder). - CharLimit(o.CharLimit). - ShowLineNumbers(o.ShowLineNumbers). - Value(&value), - ), - ). - WithWidth(o.Width). - WithHeight(o.Height). - WithTheme(theme). - WithKeyMap(keymap). - WithShowHelp(o.ShowHelp). - Run() - - if err != nil { - return err + style := textarea.Style{ + Base: o.BaseStyle.ToLipgloss(), + Placeholder: o.PlaceholderStyle.ToLipgloss(), + CursorLine: o.CursorLineStyle.ToLipgloss(), + CursorLineNumber: o.CursorLineNumberStyle.ToLipgloss(), + EndOfBuffer: o.EndOfBufferStyle.ToLipgloss(), + LineNumber: o.LineNumberStyle.ToLipgloss(), + Prompt: o.PromptStyle.ToLipgloss(), } - fmt.Println(value) + a.BlurredStyle = style + a.FocusedStyle = style + a.Cursor.Style = o.CursorStyle.ToLipgloss() + a.Cursor.SetMode(cursor.Modes[o.CursorMode]) + + a.SetWidth(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) + } + m = tm.(model) + if !m.submitted { + return errors.New("not submitted") + } + fmt.Println(m.textarea.Value()) return nil } diff --git a/write/options.go b/write/options.go index 109c3dc..63c7b0c 100644 --- a/write/options.go +++ b/write/options.go @@ -1,29 +1,36 @@ package write -import "github.com/charmbracelet/gum/style" +import ( + "time" + + "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:"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"` + 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"` - BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` - CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"` - HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"` - PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"` - PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"` - - EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"` - LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"` + BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"` CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"` + CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"` + EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"` + LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"` + HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"` + PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"` + PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"` + Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_WRITE_PADDING"` } diff --git a/write/write.go b/write/write.go new file mode 100644 index 0000000..b7e65a3 --- /dev/null +++ b/write/write.go @@ -0,0 +1,202 @@ +// 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) +}