diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cd9f34c..60f9b47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @charmbracelet/everyone +* @maaslalani diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d944991..9fc3b07 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,57 +1,20 @@ version: 2 - updates: - package-ecosystem: "gomod" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "daily" labels: - "dependencies" commit-message: - prefix: "chore" + prefix: "feat" include: "scope" - groups: - all: - patterns: - - "*" - ignore: - - dependency-name: github.com/charmbracelet/bubbletea/v2 - versions: - - v2.0.0-beta1 - - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "daily" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" - groups: - all: - patterns: - - "*" - - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2330819..5a74cbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,34 @@ name: build -on: - push: - branches: - - main - pull_request: +on: [push, pull_request] jobs: build: - uses: charmbracelet/meta/.github/workflows/build.yml@main + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + GO111MODULE: "on" + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ~1.21 + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Go modules + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v -cover -timeout=30s ./... + + snapshot: + uses: charmbracelet/meta/.github/workflows/snapshot.yml@main secrets: - gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + goreleaser_key: ${{ secrets.GORELEASER_KEY }} diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml deleted file mode 100644 index 9b08259..0000000 --- a/.github/workflows/dependabot-sync.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: dependabot-sync -on: - schedule: - - cron: "0 0 * * 0" # every Sunday at midnight - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - dependabot-sync: - uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main - with: - repo_name: ${{ github.event.repository.name }} - secrets: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml new file mode 100644 index 0000000..87d1e1f --- /dev/null +++ b/.github/workflows/lint-soft.yml @@ -0,0 +1,28 @@ +name: lint-soft +on: + push: + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + +jobs: + golangci: + name: lint-soft + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + # Optional: golangci-lint command line arguments. + args: --config .golangci-soft.yml --issues-exit-code=0 + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml deleted file mode 100644 index ecf8580..0000000 --- a/.github/workflows/lint-sync.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: lint-sync -on: - schedule: - # every Sunday at midnight - - cron: "0 0 * * 0" - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a1d6d0e..f617a5a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,26 @@ on: push: pull_request: +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint.yml@main + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + # Optional: golangci-lint command line arguments. + #args: + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true diff --git a/.golangci-soft.yml b/.golangci-soft.yml new file mode 100644 index 0000000..01d7797 --- /dev/null +++ b/.golangci-soft.yml @@ -0,0 +1,46 @@ +run: + tests: false + +issues: + include: + - EXC0001 + - EXC0005 + - EXC0011 + - EXC0012 + - EXC0013 + + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + enable: + # - dupl + - exhaustive + # - exhaustivestruct + - goconst + - godot + - godox + - gomnd + - gomoddirectives + - goprintffuncname + # - ifshort + # - lll + - misspell + - nakedret + - nestif + - noctx + - nolintlint + - prealloc + + # disable default linters, they are already enabled in .golangci.yml + disable: + - wrapcheck + - deadcode + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - structcheck + - typecheck + - varcheck diff --git a/.golangci.yml b/.golangci.yml index c90f031..a5a91d0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,22 +1,25 @@ -version: "2" run: tests: false + +issues: + include: + - EXC0001 + - EXC0005 + - EXC0011 + - EXC0012 + - EXC0013 + + max-issues-per-linter: 0 + max-same-issues: 0 + linters: enable: - bodyclose - - exhaustive - - goconst - - godot - - gomoddirectives - - goprintffuncname + - exportloopref + - goimports - gosec - - misspell - - nakedret - - nestif - nilerr - - noctx - - nolintlint - - prealloc + - predeclared - revive - rowserrcheck - sqlclosecheck @@ -24,24 +27,3 @@ linters: - unconvert - unparam - whitespace - - wrapcheck - exclusions: - rules: - - text: '(slog|log)\.\w+' - linters: - - noctx - generated: lax - presets: - - common-false-positives - settings: - exhaustive: - default-signifies-exhaustive: true -issues: - max-issues-per-linter: 0 - max-same-issues: 0 -formatters: - enable: - - gofumpt - - goimports - exclusions: - generated: lax diff --git a/.goreleaser.yml b/.goreleaser.yml index b478db6..7bad023 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,7 +1,5 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -version: 2 - includes: - from_url: url: charmbracelet/meta/main/goreleaser-full.yaml diff --git a/README.md b/README.md index 80a2b67..120ea16 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Gum +Gum +===

Gum Image @@ -20,13 +21,11 @@ The above example is running from a single shell script ([source](./examples/dem ## Tutorial Gum provides highly configurable, ready-to-use utilities to help you write -useful shell scripts and dotfile aliases with just a few lines of code. -Let's build a simple script to help you write -[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) +useful shell scripts and dotfiles aliases with just a few lines of code. +Let's build a simple script to help you write [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for your dotfiles. Ask for the commit type with gum choose: - ```bash gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert" ``` @@ -35,20 +34,17 @@ gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert" > This command itself will print to stdout which is not all that useful. To make use of the command later on you can save the stdout to a `$VARIABLE` or `file.txt`. Prompt for the scope of these changes: - ```bash gum input --placeholder "scope" ``` Prompt for the summary and description of changes: - ```bash gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change" gum write --placeholder "Details of this change" ``` Confirm before committing: - ```bash gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION" ``` @@ -68,9 +64,6 @@ brew install gum # Arch Linux (btw) pacman -S gum -# Fedora or EPEL 10 -dnf install gum - # Nix nix-env -iA nixpkgs.gum @@ -91,11 +84,10 @@ curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/ke echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list sudo apt update && sudo apt install gum ``` -

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