Compare commits

..

No commits in common. "main" and "v0.9.0" have entirely different histories.
main ... v0.9.0

82 changed files with 1599 additions and 1902 deletions

1
.github/CODEOWNERS vendored
View file

@ -1 +0,0 @@
* @maaslalani

View file

@ -12,12 +12,12 @@ jobs:
GO111MODULE: "on"
steps:
- name: Install Go
uses: actions/setup-go@v5
uses: actions/setup-go@v3
with:
go-version: ~1.21
go-version: ~1.17
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Download Go modules
run: go mod download
@ -31,4 +31,4 @@ jobs:
snapshot:
uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
secrets:
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
goreleaser_key: ${{ secrets.GORELEASER_KEY }}

View file

@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v5
uses: actions/setup-go@v3
with:
go-version: ^1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v3
with:
# Optional: golangci-lint command line arguments.
args: --config .golangci-soft.yml --issues-exit-code=0

View file

@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v5
uses: actions/setup-go@v3
with:
go-version: ^1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v3
with:
# Optional: golangci-lint command line arguments.
#args:

12
.github/workflows/soft-serve.yml vendored Normal file
View file

@ -0,0 +1,12 @@
name: soft-serve
on:
push:
branches:
- main
jobs:
soft-serve:
uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
secrets:
ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"

View file

@ -23,7 +23,7 @@ linters:
- gomnd
- gomoddirectives
- goprintffuncname
# - ifshort
- ifshort
# - lll
- misspell
- nakedret
@ -31,10 +31,10 @@ linters:
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- wrapcheck
- deadcode
- errcheck
- gosimple
@ -43,4 +43,5 @@ linters:
- staticcheck
- structcheck
- typecheck
- unused
- varcheck

View file

@ -4,7 +4,6 @@ includes:
variables:
main: "."
scoop_name: charm-gum
description: "A tool for glamorous shell scripts"
github_url: "https://github.com/charmbracelet/gum"
maintainer: "Maas Lalani <maas@charm.sh>"

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022-2024 Charmbracelet, Inc
Copyright (c) 2021 Charmbracelet, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

309
README.md
View file

@ -14,7 +14,7 @@ A tool for glamorous shell scripts. Leverage the power of
Gloss](https://github.com/charmbracelet/lipgloss) in your scripts and aliases
without writing any Go code!
<img alt="Shell running the ./demo.sh script" width="600" src="https://vhs.charm.sh/vhs-1qY57RrQlXCuydsEgDp68G.gif">
<img alt="Shell running the ./demo.sh script" width="600" src="https://stuff.charm.sh/gum/demo.gif">
The above example is running from a single shell script ([source](./examples/demo.sh)).
@ -22,36 +22,70 @@ The above example is running from a single shell script ([source](./examples/dem
Gum provides highly configurable, ready-to-use utilities to help you write
useful shell scripts and dotfiles aliases with just a few lines of code.
Let's build a simple script to help you write [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary)
for your dotfiles.
Ask for the commit type with gum choose:
Let's build a simple script to help you write [Conventional
Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for your
dotfiles.
Start with a `#!/bin/sh`.
```bash
#!/bin/sh
```
Ask for the commit type with `gum choose`:
```bash
gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert"
```
> [!NOTE]
> 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`.
> Tip: 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 an (optional) scope for the commit:
Prompt for the scope of these changes:
```bash
gum input --placeholder "scope"
```
Prompt for the summary and description of changes:
Prompt for a commit message:
```bash
gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change"
gum write --placeholder "Details of this change"
gum input --placeholder "Summary of this change"
```
Confirm before committing:
Prompt for a detailed (multi-line) explanation of the changes:
```bash
gum write --placeholder "Details of this change (CTRL+D to finish)"
```
Prompt for a confirmation before committing:
> `gum confirm` exits with status `0` if confirmed and status `1` if cancelled.
```bash
gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION"
```
Check out the [complete example](https://github.com/charmbracelet/gum/blob/main/examples/commit.sh) for combining these commands in a single script.
Putting it all together...
<img alt="Running the ./examples/commit.sh script to commit to git" width="600" src="https://vhs.charm.sh/vhs-7rRq3LsEuJVwhwr0xf6Er7.gif">
```bash
#!/bin/sh
TYPE=$(gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert")
SCOPE=$(gum input --placeholder "scope")
# Since the scope is optional, wrap it in parentheses if it has a value.
test -n "$SCOPE" && SCOPE="($SCOPE)"
# Pre-populate the input with the type(scope): so that the user may change it
SUMMARY=$(gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change")
DESCRIPTION=$(gum write --placeholder "Details of this change (CTRL+D to finish)")
# Commit these changes
gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION"
```
<img alt="Running the ./examples/commit.sh script to commit to git" width="600" src="https://stuff.charm.sh/gum/commit_2.gif">
## Installation
@ -66,27 +100,16 @@ pacman -S gum
# Nix
nix-env -iA nixpkgs.gum
# Or, with flakes
nix run "github:charmbracelet/gum" -- --help
# Windows (via WinGet or Scoop)
winget install charmbracelet.gum
scoop install charm-gum
```
<details>
<summary>Debian/Ubuntu</summary>
```bash
# Debian/Ubuntu
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
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
```
</details>
<details>
<summary>Fedora/RHEL</summary>
```bash
# Fedora/RHEL
echo '[charm]
name=Charm
baseurl=https://repo.charm.sh/yum/
@ -94,8 +117,13 @@ enabled=1
gpgcheck=1
gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
sudo yum install gum
# Alpine
apk add gum
# Android (via termux)
pkg install gum
```
</details>
Or download it:
@ -110,40 +138,25 @@ go install github.com/charmbracelet/gum@latest
[releases]: https://github.com/charmbracelet/gum/releases
## 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
## Customization
You can customize `gum` options and styles with `--flags` and `$ENVIRONMENT_VARIABLES`.
See `gum <command> --help` for a full view of each command's customization and configuration options.
`gum` is designed to be embedded in scripts and supports all sorts of use
cases. Components are configurable and customizable to fit your theme and
use case.
You can customize with `--flags`. See `gum <command> --help` for a full view of
each command's customization and configuration options.
For example, let's use an `input` and change the cursor color, prompt color,
prompt indicator, placeholder text, width, and pre-populate the value:
Customize with `--flags`:
```bash
gum input --cursor.foreground "#FF0" \
--prompt.foreground "#0FF" \
--placeholder "What's up?" \
--prompt "* " \
--width 80 \
--value "Not much, hby?"
gum input --cursor.foreground "#FF0" --prompt.foreground "#0FF" --prompt "* " \
--placeholder "What's up?" --width 80 --value "Not much, hby?"
```
Customize with `ENVIRONMENT_VARIABLES`:
You can also use `ENVIRONMENT_VARIABLES` to customize `gum` by default, this is
useful to keep a consistent theme for all your `gum` commands.
```bash
export GUM_INPUT_CURSOR_FOREGROUND="#FF0"
@ -152,54 +165,70 @@ export GUM_INPUT_PLACEHOLDER="What's up?"
export GUM_INPUT_PROMPT="* "
export GUM_INPUT_WIDTH=80
# --flags can override values set with environment
# Uses values configured through environment variables above but can still be
# overridden with flags.
gum input
```
<img alt="Gum input displaying most customization options" width="600" src="https://vhs.charm.sh/vhs-5zb9DlQYA70aL9ZpYLTwKv.gif">
<img alt="Gum input displaying most customization options" width="600" src="https://stuff.charm.sh/gum/customization.gif">
## Input
## Interaction
#### Input
Prompt for input with a simple command.
```bash
gum input > answer.txt
```
Prompt for sensitive input with the `--password` flag.
```bash
gum input --password > password.txt
```
<img src="https://vhs.charm.sh/vhs-1nScrStFI3BMlCp5yrLtyg.gif" width="600" alt="Shell running gum input typing Not much, you?" />
<img src="https://stuff.charm.sh/gum/input_1.gif" width="600" alt="Shell running gum input typing Not much, you?" />
## Write
#### Write
Prompt for some multi-line text (`ctrl+d` to complete text entry).
Prompt for some multi-line text.
Note: `CTRL+D` and `esc` are used to complete text entry. `CTRL+C` will cancel.
```bash
gum write > story.txt
```
<img src="https://vhs.charm.sh/vhs-7abdKKrUEukgx9aJj8O5GX.gif" width="600" alt="Shell running gum write typing a story" />
<img src="https://stuff.charm.sh/gum/write.gif" width="600" alt="Shell running gum write typing a story" />
## Filter
#### Filter
Filter a list of values with fuzzy matching:
Use fuzzy matching to filter a list of values:
```bash
echo Strawberry >> flavors.txt
echo Banana >> flavors.txt
echo Cherry >> flavors.txt
gum filter < flavors.txt > selection.txt
cat flavors.txt | gum filter > selection.txt
```
<img src="https://vhs.charm.sh/vhs-61euOQtKPtQVD7nDpHQhzr.gif" width="600" alt="Shell running gum filter on different bubble gum flavors" />
<img src="https://stuff.charm.sh/gum/filter.gif" width="600" alt="Shell running gum filter on different bubble gum flavors" />
Select multiple options with the `--limit` flag or `--no-limit` flag. Use `tab` or `ctrl+space` to select, `enter` to confirm.
You can also select multiple items with the `--limit` flag, which determines
the maximum number of items that can be chosen.
```bash
cat flavors.txt | gum filter --limit 2
```
Or, allow any number of selections with the `--no-limit` flag.
```bash
cat flavors.txt | gum filter --no-limit
```
## Choose
#### Choose
Choose an option from a list of choices.
@ -209,17 +238,24 @@ CARD=$(gum choose --height 15 {{A,K,Q,J},{10..2}}" "{♠,♥,♣,♦})
echo "Was your card the $CARD?"
```
You can also select multiple items with the `--limit` or `--no-limit` flag, which determines
You can also select multiple items with the `--limit` flag, which determines
the maximum of items that can be chosen.
```bash
echo "Pick your top 5 songs."
cat songs.txt | gum choose --limit 5
cat foods.txt | gum choose --no-limit --header "Grocery Shopping"
```
<img src="https://vhs.charm.sh/vhs-3zV1LvofA6Cbn5vBu1NHHl.gif" width="600" alt="Shell running gum choose with numbers and gum flavors" />
Or, allow any number of selections with the `--no-limit` flag.
## Confirm
```bash
echo "What do you need from the grocery store?"
cat foods.txt | gum choose --no-limit
```
<img src="https://stuff.charm.sh/gum/choose.gif" width="600" alt="Shell running gum choose with numbers and gum flavors" />
#### Confirm
Confirm whether to perform an action. Exits with code `0` (affirmative) or `1`
(negative) depending on selection.
@ -228,9 +264,9 @@ Confirm whether to perform an action. Exits with code `0` (affirmative) or `1`
gum confirm && rm file.txt || echo "File not removed"
```
<img src="https://vhs.charm.sh/vhs-3xRFvbeQ4lqGerbHY7y3q2.gif" width="600" alt="Shell running gum confirm" />
<img src="https://stuff.charm.sh/gum/confirm_2.gif" width="600" alt="Shell running gum confirm" />
## File
#### File
Prompt the user to select a file from the file tree.
@ -238,9 +274,9 @@ Prompt the user to select a file from the file tree.
EDITOR $(gum file $HOME)
```
<img src="https://vhs.charm.sh/vhs-2RMRqmnOPneneIgVJJ3mI1.gif" width="600" alt="Shell running gum file" />
<img src="https://stuff.charm.sh/gum/file.gif" width="600" alt="Shell running gum file" />
## Pager
#### Pager
Scroll through a long document with line numbers and a fully customizable viewport.
@ -248,24 +284,22 @@ Scroll through a long document with line numbers and a fully customizable viewpo
gum pager < README.md
```
<img src="https://vhs.charm.sh/vhs-3iMDpgOLmbYr0jrYEGbk7p.gif" width="600" alt="Shell running gum pager" />
<img src="https://stuff.charm.sh/gum/pager.gif" width="600" alt="Shell running gum pager" />
## Spin
#### Spin
Display a spinner while running a script or command. The spinner will
automatically stop after the given command exits.
To view or pipe the command's output, use the `--show-output` flag.
```bash
gum spin --spinner dot --title "Buying Bubble Gum..." -- sleep 5
```
<img src="https://vhs.charm.sh/vhs-3YFswCmoY4o3Q7MyzWl6sS.gif" width="600" alt="Shell running gum spin while sleeping for 5 seconds" />
<img src="https://stuff.charm.sh/gum/spin.gif" width="600" alt="Shell running gum spin while sleeping for 5 seconds" />
Available spinner types include: `line`, `dot`, `minidot`, `jump`, `pulse`, `points`, `globe`, `moon`, `monkey`, `meter`, `hamburger`.
## Table
#### Table
Select a row from some tabular data.
@ -273,9 +307,11 @@ Select a row from some tabular data.
gum table < flavors.csv | cut -d ',' -f 1
```
<!-- <img src="https://stuff.charm.sh/gum/table.gif" width="600" alt="Shell running gum table" /> -->
<img src="https://stuff.charm.sh/gum/table.gif" width="600" alt="Shell running gum table" />
## Style
## Styling
#### Style
Pretty print any string with any layout with one command.
@ -286,9 +322,11 @@ gum style \
'Bubble Gum (1¢)' 'So sweet and so fresh!'
```
<img src="https://github.com/charmbracelet/gum/assets/42545625/67468acf-b3e0-4e78-bd89-360739eb44fa" width="600" alt="Bubble Gum, So sweet and so fresh!" />
<img src="https://stuff.charm.sh/gum/style.gif" width="600" alt="Bubble Gum, So sweet and so fresh!" />
## Join
## Layout
#### Join
Combine text vertically or horizontally. Use this command with `gum style` to
build layouts and pretty output.
@ -307,7 +345,7 @@ BUBBLE_GUM=$(gum join "$BUBBLE" "$GUM")
gum join --align center --vertical "$I_LOVE" "$BUBBLE_GUM"
```
<img src="https://github.com/charmbracelet/gum/assets/42545625/68f7a25d-b495-48dd-982a-cee0c8ea5786" width="600" alt="I LOVE Bubble Gum written out in four boxes with double borders around them." />
<img src="https://stuff.charm.sh/gum/join.gif" width="600" alt="I LOVE Bubble Gum written out in four boxes with double borders around them." />
## Format
@ -334,95 +372,112 @@ For more information on template helpers, see the [Termenv
docs](https://github.com/muesli/termenv#template-helpers). For a full list of
named emojis see the [GitHub API](https://api.github.com/emojis).
<img src="https://github.com/charmbracelet/gum/assets/42545625/5cfbb0c8-0022-460d-841b-fec37527ca66" width="300" alt="Running gum format for different types of formats" />
## Log
`log` logs messages to the terminal at using different levels and styling using
the [`charmbracelet/log`](https://github.com/charmbracelet/log) library.
```bash
# Log some debug information.
gum log --structured --level debug "Creating file..." name file.txt
# DEBUG Unable to create file. name=temp.txt
# Log some error.
gum log --structured --level error "Unable to create file." name file.txt
# ERROR Unable to create file. name=temp.txt
# Include a timestamp.
gum log --time rfc822 --level error "Unable to create file."
```
See the Go [`time` package](https://pkg.go.dev/time#pkg-constants) for acceptable `--time` formats.
See [`charmbracelet/log`](https://github.com/charmbracelet/log) for more usage.
<img src="https://vhs.charm.sh/vhs-6jupuFM0s2fXiUrBE0I1vU.gif" width="600" alt="Running gum log with debug and error levels" />
<img src="https://stuff.charm.sh/gum/format.gif" width="600" alt="Running gum format for different types of formats" />
## Examples
How to use `gum` in your daily workflows:
See the [examples](./examples/) directory for more real world use cases.
* Write a commit message:
How to use `gum` in your daily workflows:
#### Write a commit message
Prompt for input to write git commit messages with a short summary and
longer details with `gum input` and `gum write`.
Bonus points: use `gum filter` with the [Conventional Commits
Specification](https://www.conventionalcommits.org/en/v1.0.0/#summary) as a
prefix for your commit message.
```bash
git commit -m "$(gum input --width 50 --placeholder "Summary of changes")" \
-m "$(gum write --width 80 --placeholder "Details of changes")"
-m "$(gum write --width 80 --placeholder "Details of changes (CTRL+D to finish)")"
```
* Open files in your `$EDITOR`
#### Open files in your `$EDITOR`
By default, `gum filter` will display a list of all files (searched
recursively) through your current directory, with some sensible ignore settings
(`.git`, `node_modules`). You can use this command to easily to pick a file and
open it in your `$EDITOR`.
```bash
$EDITOR $(gum filter)
```
* Connect to a `tmux` session
#### Connect to a TMUX session
Pick from a running `tmux` session and attach to it. Or, if you're already in a
`tmux` session, switch sessions.
```bash
SESSION=$(tmux list-sessions -F \#S | gum filter --placeholder "Pick session...")
tmux switch-client -t $SESSION || tmux attach -t $SESSION
```
* Pick a commit hash from `git` history
<img src="https://stuff.charm.sh/gum/pick-tmux-session.gif" width="600" alt="Picking a tmux session with gum filter" />
#### Pick commit hash from your Git history
Filter through your git history searching for commit messages, copying the
commit hash of the commit you select.
```bash
git log --oneline | gum filter | cut -d' ' -f1 # | copy
```
* Simple [`skate`](https://github.com/charmbracelet/skate) password selector.
<img src="https://stuff.charm.sh/gum/pick-commit.gif" width="600" alt="Picking a commit with gum filter" />
#### Skate Passwords
Build a simple (encrypted) password selector with [Skate](https://github.com/charmbracelet/skate).
Save all your passwords to [Skate](https://github.com/charmbracelet/skate) with `skate set github@pass.db PASSWORD`, etc...
```
skate list -k | gum filter | xargs skate get
```
* Uninstall packages
<img src="https://stuff.charm.sh/gum/skate-pass.gif" width="600" alt="Selecting a skate value with gum" />
#### Choose packages to uninstall
List all packages installed by your package manager (we'll use `brew`) and
choose which packages to uninstall.
```bash
brew list | gum choose --no-limit | xargs brew uninstall
```
* Clean up `git` branches
#### Choose branches to delete
List all branches and choose which branches to delete.
```bash
git branch | cut -c 3- | gum choose --no-limit | xargs git branch -D
```
* Checkout GitHub pull requests with [`gh`](https://cli.github.com/)
#### Choose pull request to checkout
List all PRs for the current GitHub repository and checkout the chosen PR (using [`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
#### Pick command from shell history
Pick a previously executed command from your shell history to execute, copy,
edit, etc...
```bash
gum filter < $HISTFILE --height 20
```
* `sudo` replacement
#### Sudo password input
See visual feedback when entering password with masked characters with `gum
input --password`.
```bash
alias please="gum input --password | sudo -nS"

View file

@ -1,10 +0,0 @@
package ansi
import "regexp"
var ansiEscape = regexp.MustCompile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`)
// Strip strips a string of any of it's ansi sequences.
func Strip(text string) string {
return ansiEscape.ReplaceAllString(text, "")
}

188
choose/choose.go Normal file
View file

@ -0,0 +1,188 @@
// 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/paginator"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
type model struct {
height int
cursor string
selectedPrefix string
unselectedPrefix string
cursorPrefix string
items []item
quitting bool
index int
limit int
numSelected int
paginator paginator.Model
aborted bool
// styles
cursorStyle lipgloss.Style
itemStyle lipgloss.Style
selectedItemStyle lipgloss.Style
}
type item struct {
text string
selected bool
}
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))
switch keypress := msg.String(); keypress {
case "down", "j", "ctrl+j", "ctrl+n":
m.index++
if m.index >= len(m.items) {
m.index = 0
m.paginator.Page = 0
}
if m.index >= end {
m.paginator.NextPage()
}
case "up", "k", "ctrl+k", "ctrl+p":
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 "right", "l", "ctrl+f":
m.index = clamp(m.index+m.height, 0, len(m.items)-1)
m.paginator.NextPage()
case "left", "h", "ctrl+b":
m.index = clamp(m.index-m.height, 0, len(m.items)-1)
m.paginator.PrevPage()
case "G":
m.index = len(m.items) - 1
m.paginator.Page = m.paginator.TotalPages - 1
case "g":
m.index = 0
m.paginator.Page = 0
case "a":
if m.limit <= 1 {
break
}
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.numSelected++
}
case "A":
if m.limit <= 1 {
break
}
for i := range m.items {
m.items[i].selected = false
}
m.numSelected = 0
case "ctrl+c", "esc":
m.aborted = true
m.quitting = true
return m, tea.Quit
case " ", "tab", "x":
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.numSelected++
}
case "enter":
m.quitting = true
// If the user hasn't selected any items in a multi-select.
// Then we select the item that they have pressed enter on. If they
// have selected items, then we simply return them.
if m.numSelected < 1 {
m.items[m.index].selected = true
}
return m, tea.Quit
}
}
var cmd tea.Cmd
m.paginator, cmd = m.paginator.Update(msg)
return m, cmd
}
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(" ", runewidth.StringWidth(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 {
return s.String()
}
s.WriteString(strings.Repeat("\n", m.height-m.paginator.ItemsOnPage(len(m.items))+1))
s.WriteString(" " + m.paginator.View())
return s.String()
}
//nolint:unparam
func clamp(x, min, max int) int {
if x < min {
return min
}
if x > max {
return max
}
return x
}

View file

@ -6,132 +6,143 @@ import (
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/paginator"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-isatty"
"github.com/charmbracelet/gum/ansi"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
)
const widthBuffer = 2
var (
subduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"})
verySubduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"})
)
// Run provides a shell script interface for choosing between different through
// options.
func (o Options) Run() error {
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, "\n")
o.Options = strings.Split(strings.TrimSuffix(input, "\n"), "\n")
}
if o.SelectIfOne && len(o.Options) == 1 {
fmt.Println(o.Options[0])
return nil
}
theme := huh.ThemeCharm()
options := huh.NewOptions(o.Options...)
theme.Focused.Base = lipgloss.NewStyle()
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
theme.Focused.SelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor)
theme.Focused.MultiSelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor)
theme.Focused.SelectedOption = o.SelectedItemStyle.ToLipgloss()
theme.Focused.UnselectedOption = o.ItemStyle.ToLipgloss()
theme.Focused.SelectedPrefix = o.SelectedItemStyle.ToLipgloss().SetString(o.SelectedPrefix)
theme.Focused.UnselectedPrefix = o.ItemStyle.ToLipgloss().SetString(o.UnselectedPrefix)
for _, s := range o.Selected {
for i, opt := range options {
if s == opt.Key || s == opt.Value {
options[i] = opt.Selected(true)
}
}
// We don't need to display prefixes if we are only picking one option.
// Simply displaying the cursor is enough.
if o.Limit == 1 && !o.NoLimit {
o.SelectedPrefix = ""
o.UnselectedPrefix = ""
o.CursorPrefix = ""
}
// If we've set no limit then we can simply select as many options as there
// are so let's set the limit to the number of options.
if o.NoLimit {
o.Limit = len(o.Options)
}
width := max(widest(o.Options)+
max(lipgloss.Width(o.SelectedPrefix)+lipgloss.Width(o.UnselectedPrefix))+
lipgloss.Width(o.Cursor)+1, lipgloss.Width(o.Header)+widthBuffer)
if o.Limit > 1 {
var choices []string
field := huh.NewMultiSelect[string]().
Options(options...).
Title(o.Header).
Height(o.Height).
Limit(o.Limit).
Value(&choices)
form := huh.NewForm(huh.NewGroup(field))
err := form.
WithWidth(width).
WithShowHelp(o.ShowHelp).
WithTheme(theme).
Run()
if err != nil {
return err
}
if len(choices) > 0 {
s := strings.Join(choices, "\n")
ansiprint(s)
}
return nil
if len(o.Selected) > o.Limit {
return errors.New("number of selected options cannot be greater than the limit")
}
var choice string
// Keep track of the selected items.
currentSelected := 0
// Check if selected items should be used.
hasSelectedItems := len(o.Selected) > 0
err := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Options(options...).
Title(o.Header).
Height(o.Height).
Value(&choice),
),
).
WithWidth(width).
WithTheme(theme).
WithShowHelp(o.ShowHelp).
Run()
startingIndex := 0
var items = make([]item, len(o.Options))
for i, option := range o.Options {
// Check if the option should be selected.
isSelected := hasSelectedItems && currentSelected < o.Limit && arrayContains(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++
}
}
items[i] = item{text: option, selected: isSelected}
}
// Use the pagination model to display the current and total number of
// pages.
pager := paginator.New()
pager.SetTotalPages((len(items) + o.Height - 1) / o.Height)
pager.PerPage = o.Height
pager.Type = paginator.Dots
pager.ActiveDot = subduedStyle.Render("•")
pager.InactiveDot = verySubduedStyle.Render("•")
// Disable Keybindings since we will control it ourselves.
pager.UseHLKeys = false
pager.UseLeftRightKeys = false
pager.UseJKKeys = false
pager.UsePgUpPgDownKeys = false
tm, err := tea.NewProgram(model{
index: startingIndex,
height: o.Height,
cursor: o.Cursor,
selectedPrefix: o.SelectedPrefix,
unselectedPrefix: o.UnselectedPrefix,
cursorPrefix: o.CursorPrefix,
items: items,
limit: o.Limit,
paginator: pager,
cursorStyle: o.CursorStyle.ToLipgloss(),
itemStyle: o.ItemStyle.ToLipgloss(),
selectedItemStyle: o.SelectedItemStyle.ToLipgloss(),
numSelected: currentSelected,
}, tea.WithOutput(os.Stderr)).Run()
if err != nil {
return err
return fmt.Errorf("failed to start tea program: %w", err)
}
if isatty.IsTerminal(os.Stdout.Fd()) {
fmt.Println(choice)
} else {
fmt.Print(ansi.Strip(choice))
m := tm.(model)
if m.aborted {
return exit.ErrAborted
}
var s strings.Builder
for _, item := range m.items {
if item.selected {
s.WriteString(item.text)
s.WriteRune('\n')
}
}
fmt.Print(s.String())
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
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}
func ansiprint(s string) {
if isatty.IsTerminal(os.Stdout.Fd()) {
fmt.Println(s)
} else {
fmt.Print(ansi.Strip(s))
// Check if an array contains a value.
func arrayContains(strArray []string, value string) bool {
for _, str := range strArray {
if str == value {
return true
}
}
return false
}

View file

@ -1,29 +1,20 @@
package choose
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options is the customization options for the choose command.
type Options struct {
Options []string `arg:"" optional:"" help:"Options to choose from."`
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
Ordered bool `help:"Maintain the order of the selected options" env:"GUM_CHOOSE_ORDERED"`
Height int `help:"Height of the list" default:"10" env:"GUM_CHOOSE_HEIGHT"`
Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> " env:"GUM_CHOOSE_CURSOR"`
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" env:"GUM_CHOOSE_SHOW_HELP"`
Header string `help:"Header value" default:"Choose:" env:"GUM_CHOOSE_HEADER"`
CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_CURSOR_PREFIX"`
SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"✓ " env:"GUM_CHOOSE_SELECTED_PREFIX"`
UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"`
Selected []string `help:"Options that should start as selected" default:"" env:"GUM_CHOOSE_SELECTED"`
SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"`
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`
Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0" env:"GUM_CCHOOSE_TIMEOUT"` // including timeout command options [Timeout,...]
Options []string `arg:"" optional:"" help:"Options to choose from."`
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
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"`
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"`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`
}

View file

@ -4,39 +4,44 @@ import (
"fmt"
"os"
"github.com/charmbracelet/huh"
"github.com/alecthomas/kong"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for prompting a user to confirm an
// action with an affirmative or negative answer.
func (o Options) Run() error {
theme := huh.ThemeCharm()
theme.Focused.Title = o.PromptStyle.ToLipgloss()
theme.Focused.FocusedButton = o.SelectedStyle.ToLipgloss()
theme.Focused.BlurredButton = o.UnselectedStyle.ToLipgloss()
choice := o.Default
err := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Affirmative(o.Affirmative).
Negative(o.Negative).
Title(o.Prompt).
Value(&choice),
),
).
WithTheme(theme).
WithShowHelp(o.ShowHelp).
Run()
m, err := tea.NewProgram(model{
affirmative: o.Affirmative,
negative: o.Negative,
confirmation: o.Default,
timeout: o.Timeout,
hasTimeout: o.Timeout > 0,
prompt: o.Prompt,
selectedStyle: o.SelectedStyle.ToLipgloss(),
unselectedStyle: o.UnselectedStyle.ToLipgloss(),
promptStyle: o.PromptStyle.ToLipgloss(),
}, tea.WithOutput(os.Stderr)).Run()
if err != nil {
return fmt.Errorf("unable to run confirm: %w", err)
}
if !choice {
if m.(model).aborted {
os.Exit(130)
} else if m.(model).confirmation {
os.Exit(0)
} else {
os.Exit(1)
}
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

128
confirm/confirm.go Normal file
View file

@ -0,0 +1,128 @@
// 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 (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
prompt string
affirmative string
negative string
quitting bool
aborted bool
hasTimeout bool
timeout time.Duration
confirmation bool
// styles
promptStyle lipgloss.Style
selectedStyle lipgloss.Style
unselectedStyle lipgloss.Style
}
const tickInterval = time.Second
type tickMsg struct{}
func tick() tea.Cmd {
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
return tickMsg{}
})
}
func (m model) Init() tea.Cmd {
if m.timeout > 0 {
return tick()
}
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 msg.String() {
case "ctrl+c", "esc":
m.confirmation = false
m.aborted = true
m.quitting = true
return m, tea.Quit
case "q", "n", "N":
m.confirmation = false
m.quitting = true
return m, tea.Quit
case "left", "h", "ctrl+p", "tab",
"right", "l", "ctrl+n", "shift+tab":
if m.negative == "" {
break
}
m.confirmation = !m.confirmation
case "enter":
m.quitting = true
return m, tea.Quit
case "y", "Y":
m.quitting = true
m.confirmation = true
return m, tea.Quit
}
case tickMsg:
if m.timeout <= 0 {
m.quitting = true
m.confirmation = false
return m, tea.Quit
}
m.timeout -= tickInterval
return m, tick()
}
return m, nil
}
func (m model) View() string {
if m.quitting {
return ""
}
var aff, neg, timeout string
if m.hasTimeout {
timeout = fmt.Sprintf(" (%d)", max(0, int(m.timeout.Seconds())))
}
if m.confirmation {
aff = m.selectedStyle.Render(m.affirmative)
neg = m.unselectedStyle.Render(m.negative + timeout)
} else {
aff = m.unselectedStyle.Render(m.affirmative)
neg = m.selectedStyle.Render(m.negative + timeout)
}
// If the option is intentionally empty, do not show it.
if m.negative == "" {
neg = ""
}
return lipgloss.JoinVertical(lipgloss.Center, m.promptStyle.Render(m.prompt), lipgloss.JoinHorizontal(lipgloss.Left, aff, neg))
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -8,16 +8,14 @@ import (
// Options is the customization options for the confirm command.
type Options struct {
Default bool `help:"Default confirmation action" default:"true"`
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?"`
Affirmative string `help:"The title of the affirmative action" default:"Yes"`
Negative string `help:"The title of the negative action" default:"No"`
Default bool `help:"Default confirmation action" default:"true"`
Timeout time.Duration `help:"Timeout for confirmation" default:"0" env:"GUM_CONFIRM_TIMEOUT"`
Prompt string `arg:"" help:"Prompt to display." default:"Are you sure?"`
PromptStyle style.Styles `embed:"" prefix:"prompt." help:"The style of the prompt" set:"defaultMargin=1 0 0 0" envprefix:"GUM_CONFIRM_PROMPT_"`
//nolint:staticcheck
PromptStyle style.Styles `embed:"" prefix:"prompt." help:"The style of the prompt" set:"defaultMargin=0 0 0 1" set:"defaultForeground=#7571F9" set:"defaultBold=true" envprefix:"GUM_CONFIRM_PROMPT_"`
SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style of the selected action" set:"defaultBackground=212" set:"defaultForeground=230" set:"defaultPadding=0 3" set:"defaultMargin=1 1" envprefix:"GUM_CONFIRM_SELECTED_"`
//nolint:staticcheck
SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style of the selected action" set:"defaultBackground=212" set:"defaultForeground=230" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_SELECTED_"`
//nolint:staticcheck
UnselectedStyle style.Styles `embed:"" prefix:"unselected." help:"The style of the unselected action" set:"defaultBackground=235" set:"defaultForeground=254" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_UNSELECTED_"`
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_CONFIRM_SHOW_HELP"`
Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0" env:"GUM_CONFIRM_TIMEOUT"`
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=1 1" envprefix:"GUM_CONFIRM_UNSELECTED_"`
}

View file

@ -1,12 +0,0 @@
package cursor
import (
"github.com/charmbracelet/bubbles/cursor"
)
// Modes maps strings to cursor modes.
var Modes = map[string]cursor.Mode{
"blink": cursor.CursorBlink,
"hide": cursor.CursorHide,
"static": cursor.CursorStatic,
}

View file

@ -1,12 +1,7 @@
{ pkgs }:
pkgs.buildGoModule rec {
pname = "gum";
version = "0.14.0";
pkgs.buildGoModule {
name = "gum";
src = ./.;
vendorHash = "sha256-gDDaKrwlrJyyDzgyGf9iP/XPnOAwpkvIyzCXobXrlF4=";
ldflags = [ "-s" "-w" "-X=main.Version=${version}" ];
vendorSha256 = "sha256-rOBwhPXo4sTSI3j3rn3c5qWGnGFgkpeFUKgtzKBltbg=";
}

2
examples/.gitignore vendored
View file

@ -1,2 +0,0 @@
*.gif
*.png

View file

@ -1,36 +0,0 @@
# Glamour
A casual introduction. 你好世界!
## Let's talk about artichokes
The artichoke is mentioned as a garden
plant in the 8th century BC by Homer
and Hesiod. The naturally occurring
variant of the artichoke, the cardoon,
which is native to the Mediterranean
area, also has records of use as a
food among the ancient Greeks and
Romans. Pliny the Elder mentioned
growing of 'carduus' in Carthage
and Cordoba.
He holds him with his skinny hand,
There was ship,' quoth he.
'Hold off! unhand me, grey-beard loon!'
An artichoke dropt he.
## Other foods worth mentioning
1. Carrots
2. Celery
3. Tacos
• Soft
• Hard
4. Cucumber
## Things to eat today
* Carrots
* Ramen
* Currywurst

View file

@ -1,26 +0,0 @@
Output choose.gif
Set Width 1000
Set Height 430
Set Shell bash
Type "gum choose {1..5}"
Sleep 500ms
Enter
Sleep 500ms
Down@250ms 3
Sleep 500ms
Up@250ms 2
Enter
Sleep 1.5s
Ctrl+L
Sleep 500ms
Type "gum choose --limit 2 Banana Cherry Orange"
Sleep 500ms
Enter
Sleep 500ms
Type@250ms "jxjxk"
Sleep 1s
Enter
Sleep 2s

View file

@ -10,10 +10,6 @@
#
# alias gcm='git commit -m "$(gum input)" -m "$(gum write)"'
# if [ -z "$(git status -s -uno | grep -v '^ ' | awk '{print $2}')" ]; then
# gum confirm "Stage all?" && git add .
# fi
TYPE=$(gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert")
SCOPE=$(gum input --placeholder "scope")

View file

@ -1,45 +0,0 @@
Output commit.gif
Set Shell "bash"
Set FontSize 32
Set Width 1200
Set Height 600
Type "./commit.sh" Sleep 500ms Enter
Sleep 1s
Down@250ms 2
Sleep 500ms
Enter
Sleep 500ms
Type "gum"
Sleep 500ms
Enter
Sleep 1s
Type "Gum is sooo tasty"
Sleep 500ms
Enter
Sleep 1s
Type@65ms "I love bubble gum."
Sleep 500ms
Alt+Enter
Sleep 500ms
Alt+Enter
Sleep 500ms
Type "This commit shows how much I love chewing bubble gum!!!"
Sleep 500ms
Enter
Sleep 1s
Left@400ms 3
Sleep 1s

View file

@ -1,26 +0,0 @@
Output confirm.gif
Set Width 1000
Set Height 350
Set Shell bash
Sleep 500ms
Type "gum confirm && echo 'Me too!' || echo 'Me neither.'"
Sleep 1s
Enter
Sleep 1s
Right
Sleep 500ms
Left
Sleep 500ms
Enter
Sleep 1.5s
Ctrl+L
Type "gum confirm && echo 'Me too!' || echo 'Me neither.'"
Sleep 500ms
Enter
Sleep 500ms
Right
Sleep 500ms
Enter
Sleep 1s

View file

@ -1,19 +0,0 @@
Output customize.gif
Set Width 1000
Set Height 350
Set Shell bash
Sleep 1s
Type `gum input --cursor.foreground "#F4AC45" \` Enter
Type `--prompt.foreground "#04B575" --prompt "What's up? " \` Enter
Type `--placeholder "Not much, you?" --value "Not much, you?" \` Enter
Type `--width 80` Enter
Sleep 1s
Ctrl+A
Sleep 1s
Ctrl+E
Sleep 1s
Ctrl+U
Sleep 1s

View file

@ -5,7 +5,7 @@ NAME=$(gum input --placeholder "What is your name?")
echo -e "Well, it is nice to meet you, $(gum style --foreground 212 "$NAME")."
sleep 1; clear
sleep 2; clear
echo -e "Can you tell me a $(gum style --italic --foreground 99 'secret')?\n"
@ -14,7 +14,7 @@ gum write --placeholder "I'll keep it to myself, I promise!" > /dev/null # we ke
clear; echo "What should I do with this information?"; sleep 1
READ="Read"; THINK="Think"; DISCARD="Discard"
ACTIONS=$(gum choose --no-limit "$READ" "$THINK" "$DISCARD")
ACTIONS=$(gum choose --cursor-prefix "[ ] " --selected-prefix "[✓] " --no-limit "$READ" "$THINK" "$DISCARD")
clear; echo "One moment, please."
@ -24,7 +24,8 @@ grep -q "$DISCARD" <<< "$ACTIONS" && gum spin -s monkey --title " Discarding you
sleep 1; clear
GUM=$(echo -e "Cherry\nGrape\nLime\nOrange" | gum filter --placeholder "Favorite flavor?")
echo "What's your favorite $(gum style --foreground 212 "Gum") flavor?"
GUM=$(echo -e "Cherry\nGrape\nLime\nOrange" | gum filter)
echo "I'll keep that in mind!"
sleep 1; clear
@ -38,10 +39,10 @@ CHOICE=$(gum choose --item.foreground 250 "Yes" "No" "It's complicated")
sleep 1
gum spin --title "Chewing some $(gum style --foreground "#04B575" "$GUM") bubble gum..." -- sleep 2.5
gum spin --title "Chewing some $(gum style --foreground "#04B575" "$GUM") bubble gum..." -- sleep 5
clear
NICE_MEETING_YOU=$(gum style --height 5 --width 20 --padding '1 3' --border double --border-foreground 57 "Nice meeting you, $(gum style --foreground 212 "$NAME"). See you soon!")
CHEW_BUBBLE_GUM=$(gum style --width 17 --padding '1 3' --border double --border-foreground 212 "Go chew some $(gum style --foreground "#04B575" "$GUM") bubble gum.")
NICE_MEETING_YOU=$(gum style --height 5 --width 25 --padding '1 3' --border double --border-foreground 57 "Well, it was nice meeting you, $(gum style --foreground 212 "$NAME"). Hope to see you soon!")
CHEW_BUBBLE_GUM=$(gum style --width 25 --padding '1 3' --border double --border-foreground 212 "Don't forget to chew some $(gum style --foreground "#04B575" "$GUM") bubble gum.")
gum join --horizontal "$NICE_MEETING_YOU" "$CHEW_BUBBLE_GUM"

View file

@ -1,49 +0,0 @@
Output ./demo.gif
Set Shell bash
Set FontSize 22
Set Width 800
Set Height 450
Type "./demo.sh"
Enter
Sleep 1s
Type "Walter"
Sleep 500ms
Enter
Sleep 2s
Type "Nope, sorry!"
Sleep 500ms
Alt+Enter
Sleep 200ms
Alt+Enter
Sleep 500ms
Type "I don't trust you."
Sleep 1s
Enter
Sleep 2s
Type "x" Sleep 250ms Type "j" Sleep 250ms
Type "x" Sleep 250ms Type "j" Sleep 250ms
Type "x" Sleep 1s
Enter
Sleep 6s
Type "li"
Sleep 1s
Enter
Sleep 3s
Down@500ms 2
Up@500ms 2
Sleep 1s
Enter
Sleep 6s

View file

@ -1 +0,0 @@
Banana

View file

@ -1,15 +0,0 @@
Output file.gif
Set Width 800
Set Height 525
Set Shell bash
Type "gum file .."
Enter
Sleep 1s
Down@150ms 6
Sleep 1s
Enter
Sleep 1s
Type "j"
Sleep 1s

View file

@ -1,4 +0,0 @@
Banana
Cherry
Orange
Strawberry

View file

@ -1,12 +0,0 @@
> gum format -t code < main.go
   package main
   
   import "fmt"
   
   func main() {
    fmt.Println("Charm_™ Gum")
   }
   


View file

@ -1,16 +0,0 @@
Output input.gif
Set Width 800
Set Height 250
Set Shell bash
Sleep 1s
Type `gum input --placeholder "What's up?"`
Sleep 1s
Enter
Sleep 1s
Type "Not much, you?"
Sleep 1s
Enter
Sleep 1s

View file

@ -1,47 +0,0 @@
#!/usr/bin/env bash
# If the user passes '-h', '--help', or 'help' print out a little bit of help.
# text.
case "$1" in
"-h" | "--help" | "help")
printf 'Generate kaomojis on request.\n\n'
printf 'Usage: %s [kind]\n' "$(basename "$0")"
exit 1
;;
esac
# The user can pass an argument like "bear" or "angry" to specify the general
# kind of Kaomoji produced.
sentiment=""
if [[ $1 != "" ]]; then
sentiment=" $1"
fi
# Ask mods to generate Kaomojis. Save the output in a variable.
kaomoji="$(mods "generate 10${sentiment} kaomojis. number them and put each one on its own line.")"
if [[ $kaomoji == "" ]]; then
exit 1
fi
# Pipe mods output to gum so the user can choose the perfect kaomoji. Save that
# choice in a variable. Also note that we're using cut to drop the item number
# in front of the Kaomoji.
choice="$(echo "$kaomoji" | gum choose | cut -d ' ' -f 2)"
if [[ $choice == "" ]]; then
exit 1
fi
# If xsel (X11) or pbcopy (macOS) exists, copy to the clipboard. If not, just
# print the Kaomoji.
if command -v xsel &> /dev/null; then
printf '%s' "$choice" | xclip -sel clip # X11
elif command -v pbcopy &> /dev/null; then
printf '%s' "$choice" | pbcopy # macOS
else
# We can't copy, so just print it out.
printf 'Here you go: %s\n' "$choice"
exit 0
fi
# We're done!
printf 'Copied %s to the clipboard\n' "$choice"

View file

@ -1,7 +0,0 @@
package main
import "fmt"
func main() {
fmt.Println("Charm_™ Gum")
}

View file

@ -1,15 +0,0 @@
Output pager.gif
Set Shell bash
Set Width 900
Set Height 750
Sleep 1s
Type "gum pager < README.md"
Enter
Sleep 1.5s
Down@100ms 25
Sleep 1s
Up@100ms 25
Sleep 3s

View file

@ -1,13 +0,0 @@
Output spin.gif
Set Shell bash
Set Width 1200
Set Height 300
Set FontSize 36
Sleep 500ms
Type `gum spin --title "Buying Gum..." -- sleep 5`
Sleep 1s
Enter
Sleep 4s

View file

@ -1,2 +0,0 @@
Once upon a time
In a land far, far away....

View file

@ -40,7 +40,7 @@ gum style --foreground 99 --border double --border-foreground 99 --padding "1 2"
# Write
gum write
gum write --width 40 --height 6 --placeholder "Type whatever you want" --prompt "| " --show-cursor-line --show-line-numbers --value "Something..." --base.padding 1 --cursor.foreground 99 --prompt.foreground 99
gum write --width 40 --height 3 --placeholder "Type whatever you want" --prompt "| " --show-cursor-line --show-line-numbers --value "Something..." --base.padding 1 --cursor.foreground 99 --prompt.foreground 99
# Table
gum table < table/example.csv

View file

@ -1,21 +0,0 @@
Output write.gif
Set Width 800
Set Height 350
Set Shell bash
Sleep 500ms
Type "gum write > story.txt"
Enter
Sleep 1s
Type "Once upon a time"
Sleep 1s
Alt+Enter
Type "In a land far, far away...."
Sleep 500ms
Enter
Sleep 1s
Type "cat story.txt"
Enter
Sleep 2s

View file

@ -3,10 +3,13 @@ package file
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/alecthomas/kong"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stack"
"github.com/charmbracelet/gum/style"
)
// Run is the interface to picking a file.
@ -24,40 +27,47 @@ func (o Options) Run() error {
return fmt.Errorf("file not found: %w", err)
}
theme := huh.ThemeCharm()
theme.Focused.Base = lipgloss.NewStyle()
theme.Focused.File = o.FileStyle.ToLipgloss()
theme.Focused.Directory = o.DirectoryStyle.ToLipgloss()
theme.Focused.SelectedOption = o.SelectedStyle.ToLipgloss()
keymap := huh.NewDefaultKeyMap()
keymap.FilePicker.Open.SetEnabled(false)
// XXX: These should be file selected specific.
theme.Focused.TextInput.Placeholder = o.PermissionsStyle.ToLipgloss()
theme.Focused.TextInput.Prompt = o.CursorStyle.ToLipgloss()
err = huh.NewForm(
huh.NewGroup(
huh.NewFilePicker().
Picking(true).
CurrentDirectory(path).
DirAllowed(o.Directory).
FileAllowed(o.File).
Height(o.Height).
ShowHidden(o.All).
Value(&path),
),
).
WithShowHelp(o.ShowHelp).
WithKeyMap(keymap).
WithTheme(theme).
Run()
if err != nil {
return err
m := model{
path: path,
cursor: o.Cursor,
selected: 0,
showHidden: o.All,
dirAllowed: o.Directory,
fileAllowed: o.File,
autoHeight: o.Height == 0,
height: o.Height,
max: 0,
min: 0,
selectedStack: stack.NewStack(),
minStack: stack.NewStack(),
maxStack: stack.NewStack(),
cursorStyle: o.CursorStyle.ToLipgloss().Inline(true),
symlinkStyle: o.SymlinkStyle.ToLipgloss().Inline(true),
directoryStyle: o.DirectoryStyle.ToLipgloss().Inline(true),
fileStyle: o.FileStyle.ToLipgloss().Inline(true),
permissionStyle: o.PermissionsStyle.ToLipgloss().Inline(true),
selectedStyle: o.SelectedStyle.ToLipgloss().Inline(true),
fileSizeStyle: o.FileSizeStyle.ToLipgloss().Inline(true),
}
fmt.Println(path)
tm, err := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run()
if err != nil {
return fmt.Errorf("unable to pick selection: %w", err)
}
m = tm.(model)
if m.path == "" {
os.Exit(1)
}
fmt.Println(m.path)
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

256
file/file.go Normal file
View file

@ -0,0 +1,256 @@
// 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 (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stack"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
)
const marginBottom = 5
type model struct {
quitting bool
path string
files []os.DirEntry
showHidden bool
dirAllowed bool
fileAllowed bool
selected int
selectedStack stack.Stack
min int
max int
maxStack stack.Stack
minStack stack.Stack
height int
autoHeight bool
cursor string
cursorStyle lipgloss.Style
symlinkStyle lipgloss.Style
directoryStyle lipgloss.Style
fileStyle lipgloss.Style
permissionStyle lipgloss.Style
selectedStyle lipgloss.Style
fileSizeStyle lipgloss.Style
}
type readDirMsg []os.DirEntry
func readDir(path string, showHidden bool) tea.Cmd {
return func() tea.Msg {
dirEntries, err := os.ReadDir(path)
if err != nil {
return tea.Quit
}
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return readDirMsg(dirEntries)
}
var sanitizedDirEntries []fs.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if isHidden {
continue
}
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
return readDirMsg(sanitizedDirEntries)
}
}
func (m model) Init() tea.Cmd {
return readDir(m.path, m.showHidden)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case readDirMsg:
m.files = msg
case tea.WindowSizeMsg:
if m.autoHeight {
m.height = msg.Height - marginBottom
}
m.max = m.height
case tea.KeyMsg:
switch msg.String() {
case "g":
m.selected = 0
m.min = 0
m.max = m.height - 1
case "G":
m.selected = len(m.files) - 1
m.min = len(m.files) - m.height
m.max = len(m.files) - 1
case "j", "down":
m.selected++
if m.selected >= len(m.files) {
m.selected = len(m.files) - 1
}
if m.selected > m.max {
m.min++
m.max++
}
case "k", "up":
m.selected--
if m.selected < 0 {
m.selected = 0
}
if m.selected < m.min {
m.min--
m.max--
}
case "ctrl+c", "q":
m.path = ""
m.quitting = true
return m, tea.Quit
case "backspace", "h", "left":
m.path = filepath.Dir(m.path)
if m.selectedStack.Length() > 0 {
m.selected, m.min, m.max = m.popView()
} else {
m.selected = 0
m.min = 0
m.max = m.height - 1
}
return m, readDir(m.path, m.showHidden)
case "l", "right", "enter":
if len(m.files) == 0 {
break
}
f := m.files[m.selected]
info, err := f.Info()
if err != nil {
break
}
isSymlink := info.Mode()&fs.ModeSymlink != 0
isDir := f.IsDir()
if isSymlink {
symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.path, f.Name()))
info, err := os.Stat(symlinkPath)
if err != nil {
break
}
if info.IsDir() {
isDir = true
}
}
if (!isDir && m.fileAllowed) || (isDir && m.dirAllowed) {
if msg.String() == "enter" {
m.path = filepath.Join(m.path, f.Name())
m.quitting = true
return m, tea.Quit
}
}
if !isDir {
break
}
m.path = filepath.Join(m.path, f.Name())
m.pushView()
m.selected = 0
m.min = 0
m.max = m.height - 1
return m, readDir(m.path, m.showHidden)
}
}
return m, nil
}
func (m model) pushView() {
m.minStack.Push(m.min)
m.maxStack.Push(m.max)
m.selectedStack.Push(m.selected)
}
func (m model) popView() (int, int, int) {
return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
}
func (m model) View() string {
if m.quitting {
return ""
}
if len(m.files) == 0 {
return "Bummer. No files found."
}
var s strings.Builder
for i, f := range m.files {
if i < m.min {
continue
}
if i > m.max {
break
}
var symlinkPath string
info, _ := f.Info()
isSymlink := info.Mode()&fs.ModeSymlink != 0
size := humanize.Bytes(uint64(info.Size()))
name := f.Name()
if isSymlink {
symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.path, name))
}
if m.selected == i {
selected := fmt.Sprintf(" %s %"+fmt.Sprint(m.fileSizeStyle.GetWidth())+"s %s", info.Mode().String(), size, name)
if isSymlink {
selected = fmt.Sprintf("%s → %s", selected, symlinkPath)
}
s.WriteString(m.cursorStyle.Render(m.cursor) + m.selectedStyle.Render(selected))
s.WriteRune('\n')
continue
}
var style = m.fileStyle
if f.IsDir() {
style = m.directoryStyle
} else if isSymlink {
style = m.symlinkStyle
}
fileName := style.Render(name)
if isSymlink {
fileName = fmt.Sprintf("%s → %s", fileName, symlinkPath)
}
s.WriteString(fmt.Sprintf(" %s %s %s", m.permissionStyle.Render(info.Mode().String()), m.fileSizeStyle.Render(size), fileName))
s.WriteRune('\n')
}
return s.String()
}

10
file/hidden_unix.go Normal file
View file

@ -0,0 +1,10 @@
//go:build !windows
package file
import "strings"
// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}

20
file/hidden_windows.go Normal file
View file

@ -0,0 +1,20 @@
//go:build windows
package file
import (
"syscall"
)
// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
pointer, err := syscall.UTF16PtrFromString(file)
if err != nil {
return false, err
}
attributes, err := syscall.GetFileAttributes(pointer)
if err != nil {
return false, err
}
return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
}

View file

@ -1,10 +1,6 @@
package file
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options are the options for the file command.
type Options struct {
@ -15,9 +11,8 @@ type Options struct {
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"`
Height int `help:"Maximum number of files to display" default:"0" 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_"`
@ -26,6 +21,5 @@ type Options struct {
//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"`
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_"`
}

View file

@ -6,16 +6,16 @@ import (
"os"
"strings"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/mattn/go-isatty"
"github.com/sahilm/fuzzy"
"github.com/charmbracelet/gum/ansi"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/internal/files"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for filtering through options, powered
@ -26,29 +26,25 @@ func (o Options) Run() error {
i.Prompt = o.Prompt
i.PromptStyle = o.PromptStyle.ToLipgloss()
i.PlaceholderStyle = o.PlaceholderStyle.ToLipgloss()
i.Placeholder = o.Placeholder
i.Width = o.Width
v := viewport.New(o.Width, o.Height)
if len(o.Options) == 0 {
if input, _ := stdin.Read(); input != "" {
o.Options = strings.Split(input, "\n")
} else {
o.Options = files.List()
var choices []string
if input, _ := stdin.Read(); input != "" {
input = strings.TrimSuffix(input, "\n")
if input != "" {
choices = strings.Split(input, "\n")
}
} else {
choices = files.List()
}
if len(o.Options) == 0 {
if len(choices) == 0 {
return errors.New("no options provided, see `gum filter --help`")
}
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())
@ -57,25 +53,19 @@ func (o Options) Run() error {
var matches []fuzzy.Match
if o.Value != "" {
i.SetValue(o.Value)
}
switch {
case o.Value != "" && o.Fuzzy:
matches = fuzzy.Find(o.Value, o.Options)
case o.Value != "" && !o.Fuzzy:
matches = exactMatches(o.Value, o.Options)
default:
matches = matchAll(o.Options)
matches = fuzzy.Find(o.Value, choices)
} else {
matches = matchAll(choices)
}
if o.NoLimit {
o.Limit = len(o.Options)
o.Limit = len(choices)
}
p := tea.NewProgram(model{
choices: o.Options,
choices: choices,
indicator: o.Indicator,
matches: matches,
header: o.Header,
textinput: i,
viewport: &v,
indicatorStyle: o.IndicatorStyle.ToLipgloss(),
@ -84,17 +74,12 @@ func (o Options) Run() error {
unselectedPrefixStyle: o.UnselectedPrefixStyle.ToLipgloss(),
unselectedPrefix: o.UnselectedPrefix,
matchStyle: o.MatchStyle.ToLipgloss(),
headerStyle: o.HeaderStyle.ToLipgloss(),
textStyle: o.TextStyle.ToLipgloss(),
cursorTextStyle: o.CursorTextStyle.ToLipgloss(),
height: o.Height,
selected: make(map[string]struct{}),
limit: o.Limit,
reverse: o.Reverse,
fuzzy: o.Fuzzy,
timeout: o.Timeout,
hasTimeout: o.Timeout > 0,
sort: o.Sort,
}, options...)
tm, err := p.Run()
@ -106,19 +91,15 @@ func (o Options) Run() error {
return exit.ErrAborted
}
isTTY := isatty.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, isTTY)
} else if len(m.matches) > m.cursor && m.cursor >= 0 {
if isTTY {
fmt.Println(m.matches[m.cursor].Str)
} else {
fmt.Println(ansi.Strip(m.matches[m.cursor].Str))
for k := range m.selected {
fmt.Println(k)
}
} else if len(m.matches) > m.cursor && m.cursor >= 0 {
fmt.Println(m.matches[m.cursor].Str)
}
if !o.Strict && len(m.textinput.Value()) != 0 && len(m.matches) == 0 {
@ -127,12 +108,8 @@ func (o Options) Run() error {
return nil
}
func (o Options) checkSelected(m model, isTTY bool) {
for k := range m.selected {
if isTTY {
fmt.Println(k)
} else {
fmt.Println(ansi.Strip(k))
}
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

View file

@ -12,14 +12,12 @@ package filter
import (
"strings"
"time"
"github.com/charmbracelet/gum/timeout"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/sahilm/fuzzy"
)
@ -29,7 +27,6 @@ type model struct {
choices []string
matches []fuzzy.Match
cursor int
header string
selected map[string]struct{}
limit int
numSelected int
@ -39,30 +36,22 @@ type model struct {
height int
aborted bool
quitting bool
headerStyle lipgloss.Style
matchStyle lipgloss.Style
textStyle lipgloss.Style
cursorTextStyle lipgloss.Style
indicatorStyle lipgloss.Style
selectedPrefixStyle lipgloss.Style
unselectedPrefixStyle lipgloss.Style
reverse bool
fuzzy bool
sort bool
timeout time.Duration
hasTimeout bool
}
func (m model) Init() tea.Cmd {
return timeout.Init(m.timeout, nil)
}
func (m model) Init() tea.Cmd { return nil }
func (m model) View() string {
if m.quitting {
return ""
}
var s strings.Builder
var lineTextStyle lipgloss.Style
// For reverse layout, if the number of matches is less than the viewport
// height, we need to offset the matches so that the first match is at the
@ -83,14 +72,10 @@ func (m model) View() string {
// If this is the current selected index, we add a small indicator to
// represent it. Otherwise, simply pad the string.
// The line's text style is set depending on whether or not the cursor
// points to this line.
if i == m.cursor {
s.WriteString(m.indicatorStyle.Render(m.indicator))
lineTextStyle = m.cursorTextStyle
} else {
s.WriteString(strings.Repeat(" ", lipgloss.Width(m.indicator)))
lineTextStyle = m.textStyle
s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator)))
}
// If there are multiple selections mark them, otherwise leave an empty space
@ -112,7 +97,7 @@ func (m model) View() string {
// 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()))
s.WriteString(m.textStyle.Render(buf.String()))
buf.Reset()
s.WriteString(m.matchStyle.Render(string(c)))
@ -125,7 +110,7 @@ func (m model) View() string {
}
}
// Flush text buffer.
s.WriteString(lineTextStyle.Render(buf.String()))
s.WriteString(m.textStyle.Render(buf.String()))
// We have finished displaying the match with all of it's matched
// characters highlighted and the rest filled in.
@ -136,44 +121,19 @@ func (m model) View() string {
m.viewport.SetContent(s.String())
// View the input and the filtered choices
header := m.headerStyle.Render(m.header)
if m.reverse {
view := m.viewport.View() + "\n" + m.textinput.View()
if m.header != "" {
return lipgloss.JoinVertical(lipgloss.Left, view, header)
}
return view
return m.viewport.View() + "\n" + m.textinput.View()
}
view := m.textinput.View() + "\n" + m.viewport.View()
if m.header != "" {
return lipgloss.JoinVertical(lipgloss.Left, header, view)
}
return view
return m.textinput.View() + "\n" + m.viewport.View()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case timeout.TickTimeoutMsg:
if msg.TimeoutValue <= 0 {
m.quitting = true
m.aborted = true
return m, tea.Quit
}
m.timeout = msg.TimeoutValue
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
case tea.WindowSizeMsg:
if m.height == 0 || m.height > msg.Height {
m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View())
}
// Make place in the view port if header is set
if m.header != "" {
m.viewport.Height = m.viewport.Height - lipgloss.Height(m.headerStyle.Render(m.header))
}
m.viewport.Width = msg.Width
if m.reverse {
m.viewport.YOffset = clamp(0, len(m.matches), len(m.matches)-m.viewport.Height)
@ -203,11 +163,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.ToggleSelection()
m.CursorUp()
case "ctrl+@":
if m.limit == 1 {
break // no op
}
m.ToggleSelection()
default:
m.textinput, cmd = m.textinput.Update(msg)
@ -223,11 +178,7 @@ 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.
if m.fuzzy {
if m.sort {
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
} else {
m.matches = fuzzy.FindNoSort(m.textinput.Value(), m.choices)
}
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
} else {
m.matches = exactMatches(m.textinput.Value(), m.choices)
}

View file

@ -1,39 +1,26 @@
package filter
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options is the customization options for the filter command.
type Options struct {
Options []string `arg:"" optional:"" help:"Options to filter."`
Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"`
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"`
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" default:"true" group:"Selection"`
SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"`
SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"`
UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"`
UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"`
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_FILTER_HEADER_"`
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_"`
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"`
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"`
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"`
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"`
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_FILTER_PLACEHOLDER_"`
Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"`
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"`
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"`
Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"`
Fuzzy bool `help:"Enable fuzzy matching" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
Sort bool `help:"Sort the results" default:"true" env:"GUM_FILTER_SORT" negatable:""`
Timeout time.Duration `help:"Timeout until filter command aborts" default:"0" env:"GUM_FILTER_TIMEOUT"`
Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"`
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"`
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"`
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_"`
TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"`
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"`
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"`
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"`
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"`
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" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
}

View file

@ -1,15 +1,12 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
@ -20,16 +17,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1715447595,
"narHash": "sha256-VsVAUQOj/cS1LCOmMjAGeRksXIAdPnFIjCQ0XLkCsT0=",
"lastModified": 1664107978,
"narHash": "sha256-31I9XnIjXkUa62BM1Zr/ylKMf9eVO5PtoX2mGpmB7/U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062ca2a9370a27a35c524dc82d540e6e9824b652",
"rev": "72783a2d0dbbf030bff1537873dd5b85b3fb332f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"ref": "nixos-22.05",
"repo": "nixpkgs",
"type": "github"
}
@ -39,21 +36,6 @@
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View file

@ -2,8 +2,8 @@
description = "A tool for glamorous shell scripts";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = github:nixos/nixpkgs/nixos-22.05;
flake-utils.url = github:numtide/flake-utils;
};
outputs = { self, nixpkgs, flake-utils }:

View file

@ -29,7 +29,7 @@ func (o Options) Run() error {
switch o.Type {
case "code":
output, err = code(input, o.Language)
output, err = code(input)
case "emoji":
output, err = emoji(input)
case "template":

View file

@ -9,7 +9,7 @@ import (
"github.com/muesli/termenv"
)
func code(input, language string) (string, error) {
func code(input string) (string, error) {
renderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(0),
@ -17,7 +17,7 @@ func code(input, language string) (string, error) {
if err != nil {
return "", fmt.Errorf("unable to create renderer: %w", err)
}
output, err := renderer.Render(fmt.Sprintf("```%s\n%s\n```", language, input))
output, err := renderer.Render(fmt.Sprintf("```\n%s\n```", input))
if err != nil {
return "", fmt.Errorf("unable to render: %w", err)
}
@ -54,7 +54,7 @@ func markdown(input string, theme string) (string, error) {
}
func template(input string) (string, error) {
f := termenv.TemplateFuncs(termenv.ANSI256)
f := termenv.TemplateFuncs(termenv.ColorProfile())
t, err := tpl.New("tpl").Funcs(f).Parse(input)
if err != nil {
return "", fmt.Errorf("unable to parse template: %w", err)

View file

@ -3,8 +3,7 @@ package format
// Options is customization options for the format command.
type Options struct {
Template []string `arg:"" optional:"" help:"Template string to format (can also be provided via stdin)"`
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"`
Theme string `help:"Glamour theme to use for markdown formatting" default:"pink"`
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"`
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown"`
}

66
go.mod
View file

@ -1,54 +1,26 @@
module github.com/charmbracelet/gum
go 1.21
go 1.16
require (
github.com/alecthomas/kong v0.9.0
github.com/alecthomas/kong v0.7.1
github.com/alecthomas/mango-kong v0.1.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.3
github.com/charmbracelet/glamour v0.7.0
github.com/charmbracelet/huh v0.4.2
github.com/charmbracelet/lipgloss v0.11.0
github.com/charmbracelet/log v0.4.0
github.com/mattn/go-isatty v0.0.20
github.com/muesli/reflow v0.3.0
github.com/muesli/roff v0.1.0
github.com/muesli/termenv v0.15.2
github.com/sahilm/fuzzy v0.1.1
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.1.1 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240525152034-77596eb8760e // indirect
github.com/charmbracelet/x/input v0.1.1 // indirect
github.com/charmbracelet/x/term v0.1.1 // 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.2.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a
github.com/charmbracelet/bubbletea v0.23.1
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.6.1-0.20220930064401-ae7c84f7b158
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.0
github.com/mattn/go-runewidth v0.0.14
github.com/microcosm-cc/bluemonday v1.0.21 // indirect
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.1 // indirect
github.com/yuin/goldmark-emoji v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
github.com/muesli/roff v0.1.0
github.com/muesli/termenv v0.13.0
github.com/sahilm/fuzzy v0.1.0
github.com/yuin/goldmark v1.5.2 // indirect
golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193 // indirect
golang.org/x/term v0.0.0-20221017184919-83659145692c // indirect
golang.org/x/text v0.4.0 // indirect
)

195
go.sum
View file

@ -1,115 +1,148 @@
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 v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alecthomas/kong v0.4.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0=
github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4=
github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/mango-kong v0.1.0 h1:iFVfP1k1K4qpml3JUQmD5I8MCQYfIvsD9mRdrw7jJC4=
github.com/alecthomas/mango-kong v0.1.0/go.mod h1:t+TYVdsONUolf/BwVcm+15eqcdAj15h4Qe9MMFAwwT4=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
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-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.3 h1:iXyGvI+FfOWqkB2V07m1DF3xxQijxjY2j8PqiXYqasg=
github.com/charmbracelet/bubbletea v0.26.3/go.mod h1:bpZHfDHTYJC5g+FBK+ptJRCQotRC+Dhh3AoMxa/2+3Q=
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.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
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.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk=
github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/strings v0.0.0-20240525152034-77596eb8760e h1:DhvN6ye3nHLhRtNHtlrQ0Zk+vmeN7YtEnyIRfcl7e0E=
github.com/charmbracelet/x/exp/strings v0.0.0-20240525152034-77596eb8760e/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.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4=
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a h1:/prXWlDbR4CWT1FaTvkU8WhLfpZv3eOrN9PtL8oDdDU=
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a/go.mod h1:5rZgJTHmgWISQnxnzzIJtQt3GC1bfJfNmr4SEtRDtTQ=
github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0=
github.com/charmbracelet/bubbletea v0.23.1 h1:CYdteX1wCiCzKNUlwm25ZHBIc1GXlYFyUIte8WPvhck=
github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/charmbracelet/lipgloss v0.6.1-0.20220930064401-ae7c84f7b158 h1:uuY3ti70rfEiLw3rHKSRiJ+cWfq4KWScgjxhoVRf0eE=
github.com/charmbracelet/lipgloss v0.6.1-0.20220930064401-ae7c84f7b158/go.mod h1:5EY1dcRQX7kPSA5ssoYjq2qEDhpS4cdtmdYY1OlAdMs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/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-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/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/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/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.1-0.20220205060214-77e2058169ab/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/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-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193 h1:3Moaxt4TfzNcQH6DWvlYKraN1ozhBXQHcgvXjRGeim0=
golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193/go.mod h1:RpDiru2p0u2F0lLpEoqnP2+7xs0ifAuOcJ442g6GU2s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20221017184919-83659145692c h1:dveknrit5futqEmXAvd2I1BbZIDhxRijsyWHM86NlcA=
golang.org/x/term v0.0.0-20221017184919-83659145692c/go.mod h1:VTIZ7TEbF0BS9Sv9lPTvGbtW8i4z6GGbJBCM37uMCzY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

10
gum.go
View file

@ -11,7 +11,6 @@ import (
"github.com/charmbracelet/gum/format"
"github.com/charmbracelet/gum/input"
"github.com/charmbracelet/gum/join"
"github.com/charmbracelet/gum/log"
"github.com/charmbracelet/gum/man"
"github.com/charmbracelet/gum/pager"
"github.com/charmbracelet/gum/spin"
@ -205,13 +204,4 @@ type Gum struct {
// $ gum write > output.text
//
Write write.Options `cmd:"" help:"Prompt for long-form text"`
// Log provides a shell script interface for logging using Log.
// https://github.com/charmbracelet/log
//
// It can be used to log messages to output.
//
// $ gum log --level info "Hello, world!"
//
Log log.Options `cmd:"" help:"Log messages to output"`
}

View file

@ -2,69 +2,62 @@ package input
import (
"fmt"
"os"
"github.com/charmbracelet/bubbles/key"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for the text input bubble.
// https://github.com/charmbracelet/bubbles/textinput
func (o Options) Run() error {
var value string
if o.Value != "" {
value = o.Value
} else if in, _ := stdin.Read(); in != "" {
value = in
i := textinput.New()
if in, _ := stdin.Read(); in != "" && o.Value == "" {
i.SetValue(in)
} else {
i.SetValue(o.Value)
}
theme := huh.ThemeCharm()
theme.Focused.Base = lipgloss.NewStyle()
theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss()
theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss()
theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss()
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
// Keep input keymap backwards compatible
keymap := huh.NewDefaultKeyMap()
keymap.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc"))
var echoMode huh.EchoMode
i.Focus()
i.Prompt = o.Prompt
i.Placeholder = o.Placeholder
i.Width = o.Width
i.PromptStyle = o.PromptStyle.ToLipgloss()
i.CursorStyle = o.CursorStyle.ToLipgloss()
i.CharLimit = o.CharLimit
if o.Password {
echoMode = huh.EchoModePassword
} else {
echoMode = huh.EchoModeNormal
i.EchoMode = textinput.EchoPassword
i.EchoCharacter = '•'
}
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Prompt(o.Prompt).
Placeholder(o.Placeholder).
CharLimit(o.CharLimit).
EchoMode(echoMode).
Title(o.Header).
Value(&value),
),
).
WithShowHelp(false).
WithWidth(o.Width).
WithTheme(theme).
WithKeyMap(keymap).
WithShowHelp(o.ShowHelp).
WithProgramOptions(tea.WithOutput(os.Stderr)).
Run()
p := tea.NewProgram(model{
textinput: i,
aborted: false,
header: o.Header,
headerStyle: o.HeaderStyle.ToLipgloss(),
}, tea.WithOutput(os.Stderr))
tm, err := p.Run()
if err != nil {
return err
return fmt.Errorf("failed to run input: %w", err)
}
m := tm.(model)
if m.aborted {
return exit.ErrAborted
}
fmt.Println(value)
fmt.Println(m.textinput.Value())
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

55
input/input.go Normal file
View file

@ -0,0 +1,55 @@
// 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/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
header string
headerStyle lipgloss.Style
textinput textinput.Model
quitting bool
aborted bool
}
func (m model) Init() tea.Cmd { return textinput.Blink }
func (m model) View() string {
if m.quitting {
return ""
}
if m.header != "" {
header := m.headerStyle.Render(m.header)
return lipgloss.JoinVertical(lipgloss.Left, header, m.textinput.View())
}
return m.textinput.View()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
m.quitting = true
m.aborted = true
return m, tea.Quit
case "enter":
m.quitting = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}

View file

@ -1,25 +1,17 @@
package input
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options are the customization options for the input.
type Options struct {
Placeholder string `help:"Placeholder value" default:"Type something..." env:"GUM_INPUT_PLACEHOLDER"`
Prompt string `help:"Prompt to display" default:"> " env:"GUM_INPUT_PROMPT"`
PromptStyle style.Styles `embed:"" prefix:"prompt." envprefix:"GUM_INPUT_PROMPT_"`
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_INPUT_PLACEHOLDER_"`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_INPUT_CURSOR_"`
CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_INPUT_CURSOR_MODE"`
Value string `help:"Initial value (can also be passed via stdin)" default:""`
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
Width int `help:"Input width (0 for terminal width)" default:"40" env:"GUM_INPUT_WIDTH"`
Password bool `help:"Mask input characters" default:"false"`
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:"0" env:"GUM_INPUT_TIMEOUT"`
Placeholder string `help:"Placeholder value" default:"Type something..." env:"GUM_INPUT_PLACEHOLDER"`
Prompt string `help:"Prompt to display" default:"> " env:"GUM_INPUT_PROMPT"`
PromptStyle style.Styles `embed:"" prefix:"prompt." envprefix:"GUM_INPUT_PROMPT_"`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_INPUT_CURSOR_"`
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" default:"40" env:"GUM_INPUT_WIDTH"`
Password bool `help:"Mask input characters" default:"false"`
Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"`
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"`
}

View file

@ -28,7 +28,7 @@ func Read() (string, error) {
}
}
return strings.TrimSuffix(b.String(), "\n"), nil
return b.String(), nil
}
// IsEmpty returns whether stdin is empty.

View file

@ -1,15 +0,0 @@
package utils
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// LipglossPadding calculates how much padding a string is given by a style.
func LipglossPadding(style lipgloss.Style) (int, int) {
render := style.Render(" ")
before := strings.Index(render, " ")
after := len(render) - len(" ") - before
return before, after
}

View file

@ -1,141 +0,0 @@
package log
import (
"fmt"
"math"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
)
// Run is the command-line interface for logging text.
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)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer f.Close() //nolint:errcheck
l.SetOutput(f)
}
l.SetPrefix(o.Prefix)
l.SetLevel(-math.MaxInt32) // log all levels
l.SetReportTimestamp(o.Time != "")
timeFormats := map[string]string{
"layout": time.Layout,
"ansic": time.ANSIC,
"unixdate": time.UnixDate,
"rubydate": time.RubyDate,
"rfc822": time.RFC822,
"rfc822z": time.RFC822Z,
"rfc850": time.RFC850,
"rfc1123": time.RFC1123,
"rfc1123z": time.RFC1123Z,
"rfc3339": time.RFC3339,
"rfc3339nano": time.RFC3339Nano,
"kitchen": time.Kitchen,
"stamp": time.Stamp,
"stampmilli": time.StampMilli,
"stampmicro": time.StampMicro,
"stampnano": time.StampNano,
"datetime": time.DateTime,
"dateonly": time.DateOnly,
"timeonly": time.TimeOnly,
}
tf, ok := timeFormats[strings.ToLower(o.Time)]
if ok {
l.SetTimeFormat(tf)
} else {
l.SetTimeFormat(o.Time)
}
st := log.DefaultStyles()
lvl := levelToLog[o.Level]
lvlStyle := o.LevelStyle.ToLipgloss()
if lvlStyle.GetForeground() == lipgloss.Color("") {
lvlStyle = lvlStyle.Foreground(st.Levels[lvl].GetForeground())
}
st.Levels[lvl] = lvlStyle.
SetString(strings.ToUpper(lvl.String())).
Inline(true)
st.Timestamp = o.TimeStyle.ToLipgloss().
Inline(true)
st.Prefix = o.PrefixStyle.ToLipgloss().
Inline(true)
st.Message = o.MessageStyle.ToLipgloss().
Inline(true)
st.Key = o.KeyStyle.ToLipgloss().
Inline(true)
st.Value = o.ValueStyle.ToLipgloss().
Inline(true)
st.Separator = o.SeparatorStyle.ToLipgloss().
Inline(true)
l.SetStyles(st)
switch o.Formatter {
case "json":
l.SetFormatter(log.JSONFormatter)
case "logfmt":
l.SetFormatter(log.LogfmtFormatter)
case "text":
l.SetFormatter(log.TextFormatter)
}
var arg0 string
var args []interface{}
if len(o.Text) > 0 {
arg0 = o.Text[0]
}
if len(o.Text) > 1 {
args = make([]interface{}, len(o.Text[1:]))
for i, arg := range o.Text[1:] {
args[i] = arg
}
}
logger := map[string]logger{
"none": {printf: l.Printf, print: l.Print},
"debug": {printf: l.Debugf, print: l.Debug},
"info": {printf: l.Infof, print: l.Info},
"warn": {printf: l.Warnf, print: l.Warn},
"error": {printf: l.Errorf, print: l.Error},
"fatal": {printf: l.Fatalf, print: l.Fatal},
}[o.Level]
if o.Format {
logger.printf(arg0, args...)
} else if o.Structured {
logger.print(arg0, args...)
} else {
logger.print(strings.Join(o.Text, " "))
}
return nil
}
type logger struct {
printf func(string, ...interface{})
print func(interface{}, ...interface{})
}
var levelToLog = map[string]log.Level{
"none": log.Level(math.MaxInt32),
"debug": log.DebugLevel,
"info": log.InfoLevel,
"warn": log.WarnLevel,
"error": log.ErrorLevel,
"fatal": log.FatalLevel,
}

View file

@ -1,26 +0,0 @@
package log
import (
"github.com/charmbracelet/gum/style"
)
// Options is the set of options that can configure a join.
type Options struct {
Text []string `arg:"" help:"Text to log"`
File string `short:"o" help:"Log to file"`
Format bool `short:"f" help:"Format message using printf" xor:"format,structured"`
Formatter string `help:"The log formatter to use" enum:"json,logfmt,text" default:"text"`
Level string `short:"l" help:"The log level to use" enum:"none,debug,info,warn,error,fatal" default:"none"`
Prefix string `help:"Prefix to print before the message"`
Structured bool `short:"s" help:"Use structured logging" xor:"format,structured"`
Time string `short:"t" help:"The time format to use (kitchen, layout, ansic, rfc822, etc...)" default:""`
LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"` //nolint:staticcheck
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_"`
KeyStyle style.Styles `embed:"" prefix:"key." help:"The style of the key" set:"defaultFaint=true" envprefix:"GUM_LOG_KEY_"`
ValueStyle style.Styles `embed:"" prefix:"value." help:"The style of the value" envprefix:"GUM_LOG_VALUE_"`
SeparatorStyle style.Styles `embed:"" prefix:"separator." help:"The style of the separator" set:"defaultFaint=true" envprefix:"GUM_LOG_SEPARATOR_"`
}

10
main.go
View file

@ -7,7 +7,6 @@ import (
"runtime/debug"
"github.com/alecthomas/kong"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
@ -29,7 +28,7 @@ var (
var bubbleGumPink = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
func main() {
lipgloss.SetColorProfile(termenv.NewOutput(os.Stderr).Profile)
lipgloss.SetColorProfile(termenv.ANSI256)
if Version == "" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
@ -49,9 +48,8 @@ func main() {
kong.Description(fmt.Sprintf("A tool for %s shell scripts.", bubbleGumPink.Render("glamorous"))),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
Summary: false,
NoExpandSubcommands: true,
Compact: true,
Summary: false,
}),
kong.Vars{
"version": version,
@ -73,7 +71,7 @@ func main() {
},
)
if err := ctx.Run(); err != nil {
if errors.Is(err, exit.ErrAborted) || errors.Is(err, huh.ErrUserAborted) {
if errors.Is(err, exit.ErrAborted) {
os.Exit(exit.StatusAborted)
}
fmt.Println(err)

View file

@ -16,9 +16,9 @@ func (m Man) BeforeApply(ctx *kong.Context) error {
// Set the correct man pages description without color escape sequences.
ctx.Model.Help = "A tool for glamorous shell scripts."
man := mangokong.NewManPage(1, ctx.Model)
man = man.WithSection("Copyright", "(c) 2022-2024 Charmbracelet, Inc.\n"+
man = man.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
"Released under MIT license.")
_, _ = fmt.Fprint(ctx.Stdout, man.Build(roff.NewDocument()))
fmt.Fprint(ctx.Stdout, man.Build(roff.NewDocument()))
ctx.Exit(0)
return nil
}

View file

@ -2,11 +2,12 @@ package pager
import (
"fmt"
"regexp"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for the viewport bubble.
@ -21,26 +22,19 @@ func (o Options) Run() error {
return fmt.Errorf("unable to read stdin")
}
if stdin != "" {
// Sanitize the input from stdin by removing backspace sequences.
backspace := regexp.MustCompile(".\x08")
o.Content = backspace.ReplaceAllString(stdin, "")
o.Content = stdin
} else {
return fmt.Errorf("provide some content to display")
}
}
model := model{
viewport: vp,
helpStyle: o.HelpStyle.ToLipgloss(),
content: o.Content,
origContent: o.Content,
showLineNumbers: o.ShowLineNumbers,
lineNumberStyle: o.LineNumberStyle.ToLipgloss(),
softWrap: o.SoftWrap,
matchStyle: o.MatchStyle.ToLipgloss(),
matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(),
timeout: o.Timeout,
hasTimeout: o.Timeout > 0,
viewport: vp,
helpStyle: o.HelpStyle.ToLipgloss(),
content: o.Content,
showLineNumbers: o.ShowLineNumbers,
lineNumberStyle: o.LineNumberStyle.ToLipgloss(),
softWrap: o.SoftWrap,
}
_, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil {
@ -48,3 +42,9 @@ func (o Options) Run() error {
}
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

View file

@ -1,21 +1,14 @@
package pager
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options are the options for the pager.
type Options struct {
//nolint:staticcheck
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
Content string `arg:"" optional:"" help:"Display content to scroll"`
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
SoftWrap bool `help:"Soft wrap lines" default:"false"`
MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck
MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck
Timeout time.Duration `help:"Timeout until command exits" default:"0" env:"GUM_PAGER_TIMEOUT"`
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
Content string `arg:"" optional:"" help:"Display content to scroll"`
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
SoftWrap bool `help:"Soft wrap lines" default:"false"`
}

View file

@ -1,159 +1,88 @@
// Package pager provides a pager (similar to less) for the terminal.
//
// $ cat file.txt | gum pager
// $ cat file.txt | gum page
package pager
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/gum/timeout"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/truncate"
"github.com/mattn/go-runewidth"
)
type model struct {
content string
origContent string
viewport viewport.Model
helpStyle lipgloss.Style
showLineNumbers bool
lineNumberStyle lipgloss.Style
softWrap bool
search search
matchStyle lipgloss.Style
matchHighlightStyle lipgloss.Style
maxWidth int
timeout time.Duration
hasTimeout bool
content string
viewport viewport.Model
helpStyle lipgloss.Style
showLineNumbers bool
lineNumberStyle lipgloss.Style
softWrap bool
}
func (m model) Init() tea.Cmd {
return timeout.Init(m.timeout, nil)
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case timeout.TickTimeoutMsg:
if msg.TimeoutValue <= 0 {
return m, tea.Quit
}
m.timeout = msg.TimeoutValue
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
case tea.WindowSizeMsg:
m.ProcessText(msg)
case tea.KeyMsg:
return m.KeyHandler(msg)
}
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
return m, nil
}
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
// Determine max width of a line.
m.maxWidth = m.viewport.Width
if m.softWrap {
vpStyle := m.viewport.Style
m.maxWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
if m.showLineNumbers {
m.maxWidth -= lipgloss.Width(" │ ")
}
}
for i, line := range strings.Split(m.content, "\n") {
line = strings.ReplaceAll(line, "\t", " ")
if m.showLineNumbers {
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
}
for m.softWrap && lipgloss.Width(line) > m.maxWidth {
truncatedLine := truncate.String(line, uint(m.maxWidth))
text.WriteString(textStyle.Render(truncatedLine))
text.WriteString("\n")
// Determine max width of a line
maxLineWidth := m.viewport.Width
if m.softWrap {
vpStyle := m.viewport.Style
maxLineWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
if m.showLineNumbers {
text.WriteString(m.lineNumberStyle.Render(" │ "))
maxLineWidth -= len(" │ ")
}
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())
if diffHeight > 0 && m.showLineNumbers {
remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1)
text.WriteString(m.lineNumberStyle.Render(remainingLines))
}
m.viewport.SetContent(text.String())
}
const heightOffset = 2
func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) {
var cmd tea.Cmd
if m.search.active {
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})
} else {
m.search.Done()
for i, line := range strings.Split(m.content, "\n") {
line = strings.ReplaceAll(line, "\t", " ")
if m.showLineNumbers {
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
}
case "ctrl+d", "ctrl+c", "esc":
m.search.Done()
default:
m.search.input, cmd = m.search.input.Update(key)
for m.softWrap && len(line) > maxLineWidth {
truncatedLine := runewidth.Truncate(line, maxLineWidth, "")
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(runewidth.Truncate(line, maxLineWidth, "")))
text.WriteString("\n")
}
} else {
switch key.String() {
case "g", "home":
diffHeight := m.viewport.Height - lipgloss.Height(text.String())
if diffHeight > 0 && m.showLineNumbers {
remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1)
text.WriteString(m.lineNumberStyle.Render(remainingLines))
}
m.viewport.SetContent(text.String())
case tea.KeyMsg:
switch msg.String() {
case "g":
m.viewport.GotoTop()
case "G", "end":
case "G":
m.viewport.GotoBottom()
case "/":
m.search.Begin()
case "p", "N":
m.search.PrevMatch(&m)
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 "q", "ctrl+c", "esc":
return m, tea.Quit
}
m.viewport, cmd = m.viewport.Update(key)
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string {
var timeoutStr string
if m.hasTimeout {
timeoutStr = timeout.Str(m.timeout) + " "
}
helpMsg := "\n" + timeoutStr + " ↑/↓: Navigate • q: Quit • /: Search "
if m.search.query != nil {
helpMsg += "• n: Next Match "
helpMsg += "• N: Prev Match "
}
if m.search.active {
return m.viewport.View() + "\n" + timeoutStr + " " + m.search.input.View()
}
return m.viewport.View() + m.helpStyle.Render(helpMsg)
return m.viewport.View() + m.helpStyle.Render("\n ↑/↓: Navigate • q: Quit")
}

View file

@ -1,164 +0,0 @@
package pager
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/gum/internal/utils"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/truncate"
)
type search struct {
active bool
input textinput.Model
query *regexp.Regexp
matchIndex int
matchLipglossStr string
matchString string
}
func (s *search) new() {
input := textinput.New()
input.Placeholder = "search"
input.Prompt = "/"
input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
s.input = input
}
func (s *search) Begin() {
s.new()
s.active = true
s.input.Focus()
}
// Execute find all lines in the model with a match.
func (s *search) Execute(m *model) {
defer s.Done()
if s.input.Value() == "" {
s.query = nil
return
}
var err error
s.query, err = regexp.Compile(s.input.Value())
if err != nil {
s.query = nil
return
}
query := regexp.MustCompile(fmt.Sprintf("(%s)", s.query.String()))
m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1"))
// Recompile the regex to match the an replace the highlights.
leftPad, _ := utils.LipglossPadding(m.matchStyle)
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 {
s.query = nil
}
}
func (s *search) Done() {
s.active = false
// To account for the first match is always executed.
s.matchIndex = -1
}
func (s *search) NextMatch(m *model) {
// Check that we are within bounds.
if s.query == nil {
return
}
// Remove previous highlight.
m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1)
// Highlight the next match.
allMatches := s.query.FindAllStringIndex(m.content, -1)
if len(allMatches) == 0 {
return
}
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
s.matchIndex = (s.matchIndex + 1) % len(allMatches)
match := allMatches[s.matchIndex]
lhs := m.content[:match[0]]
rhs := m.content[match[0]:]
s.matchString = m.content[match[0]:match[1]]
s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad])
m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1)
// Update the viewport position.
var line int
formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap)
index := strings.Index(formatStr, s.matchLipglossStr)
if index != -1 {
line = strings.Count(formatStr[:index], "\n")
}
// Only update if the match is not within the viewport.
if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) {
m.viewport.SetYOffset(line)
}
}
func (s *search) PrevMatch(m *model) {
// Check that we are within bounds.
if s.query == nil {
return
}
// Remove previous highlight.
m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1)
// Highlight the previous match.
allMatches := s.query.FindAllStringIndex(m.content, -1)
if len(allMatches) == 0 {
return
}
s.matchIndex = (s.matchIndex - 1) % len(allMatches)
if s.matchIndex < 0 {
s.matchIndex = len(allMatches) - 1
}
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
match := allMatches[s.matchIndex]
lhs := m.content[:match[0]]
rhs := m.content[match[0]:]
s.matchString = m.content[match[0]:match[1]]
s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad])
m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1)
// Update the viewport position.
var line int
formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap)
index := strings.Index(formatStr, s.matchLipglossStr)
if index != -1 {
line = strings.Count(formatStr[:index], "\n")
}
// Only update if the match is not within the viewport.
if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) {
m.viewport.SetYOffset(line)
}
}
func softWrapEm(str string, maxWidth int, softWrap bool) string {
var text strings.Builder
for _, line := range strings.Split(str, "\n") {
for softWrap && lipgloss.Width(line) > maxWidth {
truncatedLine := truncate.String(line, uint(maxWidth))
text.WriteString(truncatedLine)
text.WriteString("\n")
line = strings.Replace(line, truncatedLine, "", 1)
}
text.WriteString(truncate.String(line, uint(maxWidth)))
text.WriteString("\n")
}
return text.String()
}

View file

@ -4,30 +4,25 @@ import (
"fmt"
"os"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/mattn/go-isatty"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for the spinner bubble.
// https://github.com/charmbracelet/bubbles/spinner
func (o Options) Run() error {
isTTY := isatty.IsTerminal(os.Stdout.Fd())
s := spinner.New()
s.Style = o.SpinnerStyle.ToLipgloss()
s.Spinner = spinnerMap[o.Spinner]
m := model{
spinner: s,
title: o.TitleStyle.ToLipgloss().Render(o.Title),
command: o.Command,
align: o.Align,
showOutput: o.ShowOutput && isTTY,
showError: o.ShowError,
timeout: o.Timeout,
hasTimeout: o.Timeout > 0,
spinner: s,
title: o.TitleStyle.ToLipgloss().Render(o.Title),
command: o.Command,
align: o.Align,
}
p := tea.NewProgram(m, tea.WithOutput(os.Stderr))
mm, err := p.Run()
@ -37,33 +32,21 @@ func (o Options) Run() error {
return fmt.Errorf("failed to run spin: %w", err)
}
if m.aborted {
return exit.ErrAborted
if o.ShowOutput {
fmt.Fprint(os.Stdout, m.stdout)
fmt.Fprint(os.Stderr, m.stderr)
}
// If the command succeeds, and we are printing output and we are in a TTY then push the STDOUT we got to the actual
// STDOUT for piping or other things.
//nolint:nestif
if m.status == 0 {
if o.ShowOutput {
// BubbleTea writes the View() to stderr.
// If the program is being piped then put the accumulated output in stdout.
if !isTTY {
_, err := os.Stdout.WriteString(m.stdout)
if err != nil {
return fmt.Errorf("failed to write to stdout: %w", err)
}
}
}
} else if o.ShowError {
// Otherwise if we are showing errors and the command did not exit with a 0 status code then push all of the command
// output to the terminal. This way failed commands can be debugged.
_, err := os.Stdout.WriteString(m.output)
if err != nil {
return fmt.Errorf("failed to write to stdout: %w", err)
}
if m.aborted {
return exit.ErrAborted
}
os.Exit(m.status)
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

View file

@ -1,21 +1,15 @@
package spin
import (
"time"
"github.com/charmbracelet/gum/style"
)
import "github.com/charmbracelet/gum/style"
// Options is the customization options for the spin command.
type Options struct {
Command []string `arg:"" help:"Command to run"`
ShowOutput bool `help:"Show or pipe output of command during execution" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
ShowError bool `help:"Show output of command only if the command fails" default:"false" env:"GUM_SPIN_SHOW_ERROR"`
Spinner string `help:"Spinner type" short:"s" type:"spinner" enum:"line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger" default:"dot" env:"GUM_SPIN_SPINNER"`
SpinnerStyle style.Styles `embed:"" prefix:"spinner." set:"defaultForeground=212" envprefix:"GUM_SPIN_SPINNER_"`
Title string `help:"Text to display to user while spinning" default:"Loading..." env:"GUM_SPIN_TITLE"`
TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_SPIN_TITLE_"`
Align string `help:"Alignment of spinner with regard to the title" short:"a" type:"align" enum:"left,right" default:"left" env:"GUM_SPIN_ALIGN"`
Timeout time.Duration `help:"Timeout until spin command aborts" default:"0" env:"GUM_SPIN_TIMEOUT"`
ShowOutput bool `help:"Show output of command" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
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"`
}

View file

@ -15,45 +15,28 @@
package spin
import (
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/timeout"
"github.com/mattn/go-isatty"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
spinner spinner.Model
title string
align string
command []string
quitting bool
aborted bool
status int
stdout string
stderr string
output string
showOutput bool
showError bool
timeout time.Duration
hasTimeout bool
}
spinner spinner.Model
title string
align string
command []string
aborted bool
var bothbuf strings.Builder
var outbuf strings.Builder
var errbuf strings.Builder
status int
stdout string
stderr string
}
type finishCommandMsg struct {
stdout string
stderr string
output string
status int
}
@ -65,15 +48,9 @@ func commandStart(command []string) tea.Cmd {
}
cmd := exec.Command(command[0], args...) //nolint:gosec
if isatty.IsTerminal(os.Stdout.Fd()) {
stdout := io.MultiWriter(&bothbuf, &errbuf)
stderr := io.MultiWriter(&bothbuf, &outbuf)
cmd.Stdout = stdout
cmd.Stderr = stderr
} else {
cmd.Stdout = os.Stdout
}
var outbuf, errbuf strings.Builder
cmd.Stdout = &outbuf
cmd.Stderr = &errbuf
_ = cmd.Run()
@ -86,7 +63,6 @@ func commandStart(command []string) tea.Cmd {
return finishCommandMsg{
stdout: outbuf.String(),
stderr: errbuf.String(),
output: bothbuf.String(),
status: status,
}
}
@ -96,49 +72,23 @@ func (m model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
commandStart(m.command),
timeout.Init(m.timeout, nil),
)
}
func (m model) View() string {
if m.quitting && m.showOutput {
return strings.TrimPrefix(errbuf.String()+"\n"+outbuf.String(), "\n")
if m.align == "left" {
return m.spinner.View() + " " + m.title
}
var str string
if m.hasTimeout {
str = timeout.Str(m.timeout)
}
var header string
if m.align == "left" {
header = m.spinner.View() + str + " " + m.title
} else {
header = str + " " + m.title + " " + m.spinner.View()
}
if !m.showOutput {
return header
}
return header + errbuf.String() + "\n" + outbuf.String()
return m.title + " " + m.spinner.View()
}
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
m.output = msg.output
m.status = msg.status
m.quitting = true
return m, tea.Quit
case tea.KeyMsg:
switch msg.String() {

View file

@ -2,8 +2,8 @@ package style
import "github.com/charmbracelet/lipgloss"
// Border maps strings to `lipgloss.Border`s.
var Border map[string]lipgloss.Border = map[string]lipgloss.Border{
// border maps strings to `lipgloss.Border`s.
var border map[string]lipgloss.Border = map[string]lipgloss.Border{
"double": lipgloss.DoubleBorder(),
"hidden": lipgloss.HiddenBorder(),
"none": {},

View file

@ -10,6 +10,7 @@ import (
"fmt"
"strings"
"github.com/alecthomas/kong"
"github.com/charmbracelet/gum/internal/stdin"
)
@ -24,7 +25,24 @@ func (o Options) Run() error {
if text == "" {
return errors.New("no input provided, see `gum style --help`")
}
text = strings.TrimSuffix(text, "\n")
}
fmt.Println(o.Style.ToLipgloss().Render(text))
return nil
}
// HideFlags hides the flags from the usage output. This is used in conjunction
// with BeforeReset hook.
func HideFlags(ctx *kong.Context) {
n := ctx.Selected()
if n == nil {
return
}
for _, f := range n.Flags {
if g := f.Group; g != nil && g.Key == groupName {
if !strings.HasSuffix(f.Name, ".foreground") {
f.Hidden = true
}
}
}
}

View file

@ -15,28 +15,7 @@ func (s Styles) ToLipgloss() lipgloss.Style {
BorderBackground(lipgloss.Color(s.BorderBackground)).
BorderForeground(lipgloss.Color(s.BorderForeground)).
Align(decode.Align[s.Align]).
Border(Border[s.Border]).
Height(s.Height).
Width(s.Width).
Margin(parseMargin(s.Margin)).
Padding(parsePadding(s.Padding)).
Bold(s.Bold).
Faint(s.Faint).
Italic(s.Italic).
Strikethrough(s.Strikethrough).
Underline(s.Underline)
}
// ToLipgloss takes a Styles flag set and returns the corresponding
// lipgloss.Style.
func (s StylesNotHidden) ToLipgloss() lipgloss.Style {
return lipgloss.NewStyle().
Background(lipgloss.Color(s.Background)).
Foreground(lipgloss.Color(s.Foreground)).
BorderBackground(lipgloss.Color(s.BorderBackground)).
BorderForeground(lipgloss.Color(s.BorderForeground)).
Align(decode.Align[s.Align]).
Border(Border[s.Border]).
Border(border[s.Border]).
Height(s.Height).
Width(s.Width).
Margin(parseMargin(s.Margin)).

View file

@ -1,9 +1,13 @@
package style
const (
groupName = "Style Flags"
)
// Options is the customization options for the style command.
type Options struct {
Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
Style StylesNotHidden `embed:""`
Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
Style Styles `embed:""`
}
// Styles is a flag set of possible styles.
@ -14,38 +18,8 @@ type Options struct {
// components, through embedding and prefixing.
type Styles struct {
// Colors
Foreground string `help:"Foreground Color" default:"${defaultForeground}" group:"Style Flags" env:"FOREGROUND"`
Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND" hidden:"true"`
// Border
Border string `help:"Border Style" enum:"none,hidden,normal,rounded,thick,double" default:"${defaultBorder}" group:"Style Flags" env:"BORDER" hidden:"true"`
BorderBackground string `help:"Border Background Color" group:"Style Flags" default:"${defaultBorderBackground}" env:"BORDER_BACKGROUND" hidden:"true"`
BorderForeground string `help:"Border Foreground Color" group:"Style Flags" default:"${defaultBorderForeground}" env:"BORDER_FOREGROUND" hidden:"true"`
// Layout
Align string `help:"Text Alignment" enum:"left,center,right,bottom,middle,top" default:"${defaultAlign}" group:"Style Flags" env:"ALIGN" hidden:"true"`
Height int `help:"Text height" default:"${defaultHeight}" group:"Style Flags" env:"HEIGHT" hidden:"true"`
Width int `help:"Text width" default:"${defaultWidth}" group:"Style Flags" env:"WIDTH" hidden:"true"`
Margin string `help:"Text margin" default:"${defaultMargin}" group:"Style Flags" env:"MARGIN" hidden:"true"`
Padding string `help:"Text padding" default:"${defaultPadding}" group:"Style Flags" env:"PADDING" hidden:"true"`
// Format
Bold bool `help:"Bold text" default:"${defaultBold}" group:"Style Flags" env:"BOLD" hidden:"true"`
Faint bool `help:"Faint text" default:"${defaultFaint}" group:"Style Flags" env:"FAINT" hidden:"true"`
Italic bool `help:"Italicize text" default:"${defaultItalic}" group:"Style Flags" env:"ITALIC" hidden:"true"`
Strikethrough bool `help:"Strikethrough text" default:"${defaultStrikethrough}" group:"Style Flags" env:"STRIKETHROUGH" hidden:"true"`
Underline bool `help:"Underline text" default:"${defaultUnderline}" group:"Style Flags" env:"UNDERLINE" hidden:"true"`
}
// StylesNotHidden allows the style struct to display full help when not-embedded.
//
// NB: We must duplicate this struct to ensure that `gum style` does not hide
// flags when an error pops up. Ideally, we can dynamically hide or show flags
// based on the command run: https://github.com/alecthomas/kong/issues/316
type StylesNotHidden 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"`
Foreground string `help:"Foreground Color" default:"${defaultForeground}" group:"Style Flags" env:"FOREGROUND"`
// Border
Border string `help:"Border Style" enum:"none,hidden,normal,rounded,thick,double" default:"${defaultBorder}" group:"Style Flags" env:"BORDER"`

View file

@ -5,10 +5,10 @@ import (
"fmt"
"os"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
ltable "github.com/charmbracelet/lipgloss/table"
"github.com/mattn/go-runewidth"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
@ -56,10 +56,10 @@ func (o Options) Run() error {
if err != nil {
return fmt.Errorf("invalid data provided")
}
columns := make([]table.Column, 0, len(columnNames))
var columns = make([]table.Column, 0, len(columnNames))
for i, title := range columnNames {
width := lipgloss.Width(title)
width := runewidth.StringWidth(title)
if len(o.Widths) > i {
width = o.Widths[i]
}
@ -74,10 +74,10 @@ func (o Options) Run() error {
styles := table.Styles{
Cell: defaultStyles.Cell.Inherit(o.CellStyle.ToLipgloss()),
Header: defaultStyles.Header.Inherit(o.HeaderStyle.ToLipgloss()),
Selected: o.SelectedStyle.ToLipgloss(),
Selected: defaultStyles.Selected.Inherit(o.SelectedStyle.ToLipgloss()),
}
rows := make([]table.Row, 0, len(data))
var rows = make([]table.Row, 0, len(data))
for _, row := range data {
if len(row) > len(columns) {
return fmt.Errorf("invalid number of columns")
@ -85,23 +85,6 @@ func (o Options) Run() error {
rows = append(rows, table.Row(row))
}
if o.Print {
table := ltable.New().
Headers(columnNames...).
Rows(data...).
BorderStyle(o.BorderStyle.ToLipgloss()).
Border(style.Border[o.Border]).
StyleFunc(func(row, _ int) lipgloss.Style {
if row == 0 {
return styles.Header
}
return styles.Cell
})
fmt.Println(table.Render())
return nil
}
table := table.New(
table.WithColumns(columns),
table.WithFocused(true),
@ -111,6 +94,7 @@ func (o Options) Run() error {
)
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)
}
@ -129,3 +113,9 @@ func (o Options) Run() error {
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

View file

@ -4,16 +4,12 @@ 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:"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_"`
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"`
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_"`
File string `short:"f" help:"file path" default:""`
}

View file

@ -39,7 +39,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selected = m.table.SelectedRow()
m.quitting = true
return m, tea.Quit
case "ctrl+c", "q", "esc":
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
}

View file

@ -1,55 +0,0 @@
package timeout
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// Tick interval.
const tickInterval = time.Second
// TickTimeoutMsg will be dispatched for every tick.
// Containing current timeout value
// and optional parameter to be used when handling the timeout msg.
type TickTimeoutMsg struct {
TimeoutValue time.Duration
Data interface{}
}
// Init Start Timeout ticker using with timeout in seconds and optional data.
func Init(timeout time.Duration, data interface{}) tea.Cmd {
if timeout > 0 {
return Tick(timeout, data)
}
return nil
}
// Start ticker.
func Tick(timeoutValue time.Duration, data interface{}) tea.Cmd {
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
// every tick checks if the timeout needs to be decremented
// and send as message
if timeoutValue >= 0 {
timeoutValue -= tickInterval
return TickTimeoutMsg{
TimeoutValue: timeoutValue,
Data: data,
}
}
return nil
})
}
// Str produce Timeout String to be rendered.
func Str(timeout time.Duration) string {
return fmt.Sprintf(" (%d)", max(0, int(timeout.Seconds())))
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -2,10 +2,15 @@ package write
import (
"fmt"
"strings"
"os"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/exit"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for the text area bubble.
@ -13,42 +18,55 @@ import (
func (o Options) Run() error {
in, _ := stdin.Read()
if in != "" && o.Value == "" {
o.Value = strings.ReplaceAll(in, "\r", "")
o.Value = in
}
var value = o.Value
a := textarea.New()
a.Focus()
theme := huh.ThemeCharm()
theme.Focused.Base = o.BaseStyle.ToLipgloss()
theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss()
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss()
theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss()
a.Prompt = o.Prompt
a.Placeholder = o.Placeholder
a.ShowLineNumbers = o.ShowLineNumbers
a.CharLimit = o.CharLimit
keymap := huh.NewDefaultKeyMap()
keymap.Text.NewLine.SetHelp("ctrl+j", "new line")
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(),
}
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.BlurredStyle = style
a.FocusedStyle = style
a.Cursor.Style = o.CursorStyle.ToLipgloss()
a.SetWidth(o.Width)
a.SetHeight(o.Height)
a.SetValue(o.Value)
p := tea.NewProgram(model{
textarea: a,
header: o.Header,
headerStyle: o.HeaderStyle.ToLipgloss(),
}, tea.WithOutput(os.Stderr))
tm, err := p.Run()
if err != nil {
return err
return fmt.Errorf("failed to run write: %w", err)
}
m := tm.(model)
if m.aborted {
return exit.ErrAborted
}
fmt.Println(value)
fmt.Println(m.textarea.Value())
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

View file

@ -4,7 +4,7 @@ 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:"50" env:"GUM_WRITE_WIDTH"`
Width int `help:"Text area 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"`
@ -13,17 +13,14 @@ type Options struct {
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_"`
EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"`
LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"`
BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"`
CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"`
CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"`
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"`
EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"`
LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"`
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"`
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"`
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"`
}

57
write/write.go Normal file
View file

@ -0,0 +1,57 @@
// 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 (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
aborted bool
header string
headerStyle lipgloss.Style
quitting bool
textarea textarea.Model
}
func (m model) Init() tea.Cmd { return textarea.Blink }
func (m model) View() string {
if m.quitting {
return ""
}
// Display the header above the text area if it is not empty.
if m.header != "" {
header := m.headerStyle.Render(m.header)
return lipgloss.JoinVertical(lipgloss.Left, header, m.textarea.View())
}
return m.textarea.View()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
m.aborted = true
m.quitting = true
return m, tea.Quit
case "ctrl+d":
m.quitting = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}