mirror of
https://github.com/charmbracelet/gum
synced 2026-03-15 06:05:45 +01:00
Compare commits
No commits in common. "main" and "v0.14.4" have entirely different histories.
75 changed files with 1025 additions and 2769 deletions
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
43
.github/dependabot.yml
vendored
43
.github/dependabot.yml
vendored
|
|
@ -1,57 +1,20 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "America/New_York"
|
||||
interval: "daily"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
prefix: "feat"
|
||||
include: "scope"
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: github.com/charmbracelet/bubbletea/v2
|
||||
versions:
|
||||
- v2.0.0-beta1
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "America/New_York"
|
||||
interval: "daily"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "America/New_York"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
|
|
|||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
|
|
@ -1,13 +1,34 @@
|
|||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: charmbracelet/meta/.github/workflows/build.yml@main
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
GO111MODULE: "on"
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ~1.21
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v -cover -timeout=30s ./...
|
||||
|
||||
snapshot:
|
||||
uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
|
||||
secrets:
|
||||
gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
|
||||
|
|
|
|||
17
.github/workflows/dependabot-sync.yml
vendored
17
.github/workflows/dependabot-sync.yml
vendored
|
|
@ -1,17 +0,0 @@
|
|||
name: dependabot-sync
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # every Sunday at midnight
|
||||
workflow_dispatch: # allows manual triggering
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot-sync:
|
||||
uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main
|
||||
with:
|
||||
repo_name: ${{ github.event.repository.name }}
|
||||
secrets:
|
||||
gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
28
.github/workflows/lint-soft.yml
vendored
Normal file
28
.github/workflows/lint-soft.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: lint-soft
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Optional: allow read access to pull request. Use with `only-new-issues` option.
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint-soft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ^1
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
# Optional: golangci-lint command line arguments.
|
||||
args: --config .golangci-soft.yml --issues-exit-code=0
|
||||
# Optional: show only new issues if it's a pull request. The default value is `false`.
|
||||
only-new-issues: true
|
||||
14
.github/workflows/lint-sync.yml
vendored
14
.github/workflows/lint-sync.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
name: lint-sync
|
||||
on:
|
||||
schedule:
|
||||
# every Sunday at midnight
|
||||
- cron: "0 0 * * 0"
|
||||
workflow_dispatch: # allows manual triggering
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main
|
||||
24
.github/workflows/lint.yml
vendored
24
.github/workflows/lint.yml
vendored
|
|
@ -3,6 +3,26 @@ on:
|
|||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Optional: allow read access to pull request. Use with `only-new-issues` option.
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: charmbracelet/meta/.github/workflows/lint.yml@main
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ^1
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
# Optional: golangci-lint command line arguments.
|
||||
#args:
|
||||
# Optional: show only new issues if it's a pull request. The default value is `false`.
|
||||
only-new-issues: true
|
||||
|
|
|
|||
46
.golangci-soft.yml
Normal file
46
.golangci-soft.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
run:
|
||||
tests: false
|
||||
|
||||
issues:
|
||||
include:
|
||||
- EXC0001
|
||||
- EXC0005
|
||||
- EXC0011
|
||||
- EXC0012
|
||||
- EXC0013
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# - dupl
|
||||
- exhaustive
|
||||
# - exhaustivestruct
|
||||
- goconst
|
||||
- godot
|
||||
- godox
|
||||
- gomnd
|
||||
- gomoddirectives
|
||||
- goprintffuncname
|
||||
# - ifshort
|
||||
# - lll
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
|
||||
# disable default linters, they are already enabled in .golangci.yml
|
||||
disable:
|
||||
- wrapcheck
|
||||
- deadcode
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- typecheck
|
||||
- varcheck
|
||||
|
|
@ -1,22 +1,25 @@
|
|||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
|
||||
issues:
|
||||
include:
|
||||
- EXC0001
|
||||
- EXC0005
|
||||
- EXC0011
|
||||
- EXC0012
|
||||
- EXC0013
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- exhaustive
|
||||
- goconst
|
||||
- godot
|
||||
- gomoddirectives
|
||||
- goprintffuncname
|
||||
- exportloopref
|
||||
- goimports
|
||||
- gosec
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
|
|
@ -24,24 +27,3 @@ linters:
|
|||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
settings:
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
||||
version: 2
|
||||
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-full.yaml
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -20,7 +20,7 @@ The above example is running from a single shell script ([source](./examples/dem
|
|||
## Tutorial
|
||||
|
||||
Gum provides highly configurable, ready-to-use utilities to help you write
|
||||
useful shell scripts and dotfile aliases with just a few lines of code.
|
||||
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.
|
||||
|
|
@ -68,9 +68,6 @@ brew install gum
|
|||
# Arch Linux (btw)
|
||||
pacman -S gum
|
||||
|
||||
# Fedora or EPEL 10
|
||||
dnf install gum
|
||||
|
||||
# Nix
|
||||
nix-env -iA nixpkgs.gum
|
||||
|
||||
|
|
@ -116,19 +113,6 @@ sudo zypper install gum
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>FreeBSD</summary>
|
||||
|
||||
```bash
|
||||
# packages
|
||||
sudo pkg install gum
|
||||
|
||||
# ports
|
||||
cd /usr/ports/devel/gum && sudo make install clean
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Or download it:
|
||||
|
||||
- [Packages][releases] are available in Debian, RPM, and Alpine formats
|
||||
|
|
@ -267,7 +251,7 @@ gum confirm && rm file.txt || echo "File not removed"
|
|||
Prompt the user to select a file from the file tree.
|
||||
|
||||
```bash
|
||||
$EDITOR $(gum file $HOME)
|
||||
EDITOR $(gum file $HOME)
|
||||
```
|
||||
|
||||
<img src="https://vhs.charm.sh/vhs-2RMRqmnOPneneIgVJJ3mI1.gif" width="600" alt="Shell running gum file" />
|
||||
|
|
@ -415,7 +399,7 @@ $EDITOR $(gum filter)
|
|||
|
||||
```bash
|
||||
SESSION=$(tmux list-sessions -F \#S | gum filter --placeholder "Pick session...")
|
||||
tmux switch-client -t "$SESSION" || tmux attach -t "$SESSION"
|
||||
tmux switch-client -t $SESSION || tmux attach -t $SESSION
|
||||
```
|
||||
|
||||
- Pick a commit hash from `git` history
|
||||
|
|
@ -460,12 +444,6 @@ gum filter < $HISTFILE --height 20
|
|||
alias please="gum input --password | sudo -nS"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [contributing][contribute].
|
||||
|
||||
[contribute]: https://github.com/charmbracelet/gum/contribute
|
||||
|
||||
## Feedback
|
||||
|
||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||
|
|
|
|||
289
choose/choose.go
289
choose/choose.go
|
|
@ -1,289 +0,0 @@
|
|||
// Package choose provides an interface to choose one option from a given list
|
||||
// of options. The options can be provided as (new-line separated) stdin or a
|
||||
// list of arguments.
|
||||
//
|
||||
// It is different from the filter command as it does not provide a fuzzy
|
||||
// finding input, so it is best used for smaller lists of options.
|
||||
//
|
||||
// Let's pick from a list of gum flavors:
|
||||
//
|
||||
// $ gum choose "Strawberry" "Banana" "Cherry"
|
||||
package choose
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/paginator"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/exp/ordered"
|
||||
)
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
return keymap{
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j", "ctrl+j", "ctrl+n"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k", "ctrl+k", "ctrl+p"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l", "ctrl+f"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h", "ctrl+b"),
|
||||
),
|
||||
Home: key.NewBinding(
|
||||
key.WithKeys("g", "home"),
|
||||
),
|
||||
End: key.NewBinding(
|
||||
key.WithKeys("G", "end"),
|
||||
),
|
||||
ToggleAll: key.NewBinding(
|
||||
key.WithKeys("a", "A", "ctrl+a"),
|
||||
key.WithHelp("ctrl+a", "select all"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
Toggle: key.NewBinding(
|
||||
key.WithKeys(" ", "tab", "x", "ctrl+@"),
|
||||
key.WithHelp("x", "toggle"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "abort"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
Submit: key.NewBinding(
|
||||
key.WithKeys("enter", "ctrl+q"),
|
||||
key.WithHelp("enter", "submit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type keymap struct {
|
||||
Down,
|
||||
Up,
|
||||
Right,
|
||||
Left,
|
||||
Home,
|
||||
End,
|
||||
ToggleAll,
|
||||
Toggle,
|
||||
Abort,
|
||||
Quit,
|
||||
Submit key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.Toggle,
|
||||
key.NewBinding(
|
||||
key.WithKeys("up", "down", "right", "left"),
|
||||
key.WithHelp("←↓↑→", "navigate"),
|
||||
),
|
||||
k.Submit,
|
||||
k.ToggleAll,
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
height int
|
||||
padding []int
|
||||
cursor string
|
||||
selectedPrefix string
|
||||
unselectedPrefix string
|
||||
cursorPrefix string
|
||||
header string
|
||||
items []item
|
||||
quitting bool
|
||||
submitted bool
|
||||
index int
|
||||
limit int
|
||||
numSelected int
|
||||
currentOrder int
|
||||
paginator paginator.Model
|
||||
showHelp bool
|
||||
help help.Model
|
||||
keymap keymap
|
||||
|
||||
// styles
|
||||
cursorStyle lipgloss.Style
|
||||
headerStyle lipgloss.Style
|
||||
itemStyle lipgloss.Style
|
||||
selectedItemStyle lipgloss.Style
|
||||
}
|
||||
|
||||
type item struct {
|
||||
text string
|
||||
selected bool
|
||||
order int
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
start, end := m.paginator.GetSliceBounds(len(m.items))
|
||||
km := m.keymap
|
||||
switch {
|
||||
case key.Matches(msg, km.Down):
|
||||
m.index++
|
||||
if m.index >= len(m.items) {
|
||||
m.index = 0
|
||||
m.paginator.Page = 0
|
||||
}
|
||||
if m.index >= end {
|
||||
m.paginator.NextPage()
|
||||
}
|
||||
case key.Matches(msg, km.Up):
|
||||
m.index--
|
||||
if m.index < 0 {
|
||||
m.index = len(m.items) - 1
|
||||
m.paginator.Page = m.paginator.TotalPages - 1
|
||||
}
|
||||
if m.index < start {
|
||||
m.paginator.PrevPage()
|
||||
}
|
||||
case key.Matches(msg, km.Right):
|
||||
m.index = ordered.Clamp(m.index+m.height, 0, len(m.items)-1)
|
||||
m.paginator.NextPage()
|
||||
case key.Matches(msg, km.Left):
|
||||
m.index = ordered.Clamp(m.index-m.height, 0, len(m.items)-1)
|
||||
m.paginator.PrevPage()
|
||||
case key.Matches(msg, km.End):
|
||||
m.index = len(m.items) - 1
|
||||
m.paginator.Page = m.paginator.TotalPages - 1
|
||||
case key.Matches(msg, km.Home):
|
||||
m.index = 0
|
||||
m.paginator.Page = 0
|
||||
case key.Matches(msg, km.ToggleAll):
|
||||
if m.limit <= 1 {
|
||||
break
|
||||
}
|
||||
if m.numSelected < len(m.items) && m.numSelected < m.limit {
|
||||
m = m.selectAll()
|
||||
} else {
|
||||
m = m.deselectAll()
|
||||
}
|
||||
case key.Matches(msg, km.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Abort):
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
case key.Matches(msg, km.Toggle):
|
||||
if m.limit == 1 {
|
||||
break // no op
|
||||
}
|
||||
|
||||
if m.items[m.index].selected {
|
||||
m.items[m.index].selected = false
|
||||
m.numSelected--
|
||||
} else if m.numSelected < m.limit {
|
||||
m.items[m.index].selected = true
|
||||
m.items[m.index].order = m.currentOrder
|
||||
m.numSelected++
|
||||
m.currentOrder++
|
||||
}
|
||||
case key.Matches(msg, km.Submit):
|
||||
m.quitting = true
|
||||
if m.limit <= 1 && m.numSelected < 1 {
|
||||
m.items[m.index].selected = true
|
||||
}
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.paginator, cmd = m.paginator.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) selectAll() model {
|
||||
for i := range m.items {
|
||||
if m.numSelected >= m.limit {
|
||||
break // do not exceed given limit
|
||||
}
|
||||
if m.items[i].selected {
|
||||
continue
|
||||
}
|
||||
m.items[i].selected = true
|
||||
m.items[i].order = m.currentOrder
|
||||
m.numSelected++
|
||||
m.currentOrder++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) deselectAll() model {
|
||||
for i := range m.items {
|
||||
m.items[i].selected = false
|
||||
m.items[i].order = 0
|
||||
}
|
||||
m.numSelected = 0
|
||||
m.currentOrder = 0
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s strings.Builder
|
||||
|
||||
start, end := m.paginator.GetSliceBounds(len(m.items))
|
||||
for i, item := range m.items[start:end] {
|
||||
if i == m.index%m.height {
|
||||
s.WriteString(m.cursorStyle.Render(m.cursor))
|
||||
} else {
|
||||
s.WriteString(strings.Repeat(" ", lipgloss.Width(m.cursor)))
|
||||
}
|
||||
|
||||
if item.selected {
|
||||
s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text))
|
||||
} else if i == m.index%m.height {
|
||||
s.WriteString(m.cursorStyle.Render(m.cursorPrefix + item.text))
|
||||
} else {
|
||||
s.WriteString(m.itemStyle.Render(m.unselectedPrefix + item.text))
|
||||
}
|
||||
if i != m.height {
|
||||
s.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if m.paginator.TotalPages > 1 {
|
||||
s.WriteString(strings.Repeat("\n", m.height-m.paginator.ItemsOnPage(len(m.items))+1))
|
||||
s.WriteString(" " + m.paginator.View())
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
if m.header != "" {
|
||||
parts = append(parts, m.headerStyle.Render(m.header))
|
||||
}
|
||||
parts = append(parts, s.String())
|
||||
if m.showHelp {
|
||||
parts = append(parts, "", m.help.View(m.keymap))
|
||||
}
|
||||
|
||||
view := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(view)
|
||||
}
|
||||
|
|
@ -4,179 +4,132 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/paginator"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/internal/tty"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/term"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
)
|
||||
|
||||
const widthBuffer = 2
|
||||
|
||||
// Run provides a shell script interface for choosing between different through
|
||||
// options.
|
||||
func (o Options) Run() error {
|
||||
var (
|
||||
subduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"})
|
||||
verySubduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"})
|
||||
)
|
||||
|
||||
input, _ := stdin.Read(stdin.StripANSI(o.StripANSI))
|
||||
if len(o.Options) > 0 && len(o.Selected) == 0 {
|
||||
o.Selected = strings.Split(input, o.InputDelimiter)
|
||||
} else if len(o.Options) == 0 {
|
||||
if len(o.Options) <= 0 {
|
||||
input, _ := stdin.Read()
|
||||
if input == "" {
|
||||
return errors.New("no options provided, see `gum choose --help`")
|
||||
}
|
||||
o.Options = strings.Split(input, o.InputDelimiter)
|
||||
}
|
||||
|
||||
// normalize options into a map
|
||||
options := map[string]string{}
|
||||
// keep the labels in the user-provided order
|
||||
var labels []string //nolint:prealloc
|
||||
for _, opt := range o.Options {
|
||||
if o.LabelDelimiter == "" {
|
||||
options[opt] = opt
|
||||
continue
|
||||
}
|
||||
label, value, ok := strings.Cut(opt, o.LabelDelimiter)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid option format: %q", opt)
|
||||
}
|
||||
labels = append(labels, label)
|
||||
options[label] = value
|
||||
}
|
||||
if o.LabelDelimiter != "" {
|
||||
o.Options = labels
|
||||
o.Options = strings.Split(input, "\n")
|
||||
}
|
||||
|
||||
if o.SelectIfOne && len(o.Options) == 1 {
|
||||
fmt.Println(options[o.Options[0]])
|
||||
fmt.Println(o.Options[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
// We don't need to display prefixes if we are only picking one option.
|
||||
// Simply displaying the cursor is enough.
|
||||
if o.Limit == 1 && !o.NoLimit {
|
||||
o.SelectedPrefix = ""
|
||||
o.UnselectedPrefix = ""
|
||||
o.CursorPrefix = ""
|
||||
}
|
||||
theme := huh.ThemeCharm()
|
||||
options := huh.NewOptions(o.Options...)
|
||||
|
||||
if o.NoLimit {
|
||||
o.Limit = len(o.Options) + 1
|
||||
}
|
||||
theme.Focused.Base = lipgloss.NewStyle()
|
||||
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
|
||||
theme.Focused.SelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor)
|
||||
theme.Focused.MultiSelectSelector = o.CursorStyle.ToLipgloss().SetString(o.Cursor)
|
||||
theme.Focused.SelectedOption = o.SelectedItemStyle.ToLipgloss()
|
||||
theme.Focused.UnselectedOption = o.ItemStyle.ToLipgloss()
|
||||
theme.Focused.SelectedPrefix = o.SelectedItemStyle.ToLipgloss().SetString(o.SelectedPrefix)
|
||||
theme.Focused.UnselectedPrefix = o.ItemStyle.ToLipgloss().SetString(o.UnselectedPrefix)
|
||||
|
||||
if o.Ordered {
|
||||
slices.SortFunc(o.Options, strings.Compare)
|
||||
}
|
||||
|
||||
isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*"
|
||||
|
||||
// Keep track of the selected items.
|
||||
currentSelected := 0
|
||||
// Check if selected items should be used.
|
||||
hasSelectedItems := len(o.Selected) > 0
|
||||
startingIndex := 0
|
||||
currentOrder := 0
|
||||
items := make([]item, len(o.Options))
|
||||
for i, option := range o.Options {
|
||||
var order int
|
||||
// Check if the option should be selected.
|
||||
isSelected := hasSelectedItems && currentSelected < o.Limit && (isSelectAll || slices.Contains(o.Selected, option))
|
||||
// If the option is selected then increment the current selected count.
|
||||
if isSelected {
|
||||
if o.Limit == 1 {
|
||||
// When the user can choose only one option don't select the option but
|
||||
// start with the cursor hovering over it.
|
||||
startingIndex = i
|
||||
isSelected = false
|
||||
} else {
|
||||
currentSelected++
|
||||
order = currentOrder
|
||||
currentOrder++
|
||||
for _, s := range o.Selected {
|
||||
for i, opt := range options {
|
||||
if s == opt.Key || s == opt.Value {
|
||||
options[i] = opt.Selected(true)
|
||||
}
|
||||
}
|
||||
items[i] = item{text: option, selected: isSelected, order: order}
|
||||
}
|
||||
|
||||
// Use the pagination model to display the current and total number of
|
||||
// pages.
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
pager := paginator.New()
|
||||
pager.SetTotalPages((len(items) + o.Height - 1) / o.Height)
|
||||
pager.PerPage = o.Height
|
||||
pager.Type = paginator.Dots
|
||||
pager.ActiveDot = subduedStyle.Render("•")
|
||||
pager.InactiveDot = verySubduedStyle.Render("•")
|
||||
pager.KeyMap = paginator.KeyMap{}
|
||||
pager.Page = startingIndex / o.Height
|
||||
width := max(widest(o.Options)+
|
||||
max(lipgloss.Width(o.SelectedPrefix)+lipgloss.Width(o.UnselectedPrefix))+
|
||||
lipgloss.Width(o.Cursor)+1, lipgloss.Width(o.Header)+widthBuffer)
|
||||
|
||||
km := defaultKeymap()
|
||||
if o.NoLimit || o.Limit > 1 {
|
||||
km.Toggle.SetEnabled(true)
|
||||
}
|
||||
if o.NoLimit {
|
||||
km.ToggleAll.SetEnabled(true)
|
||||
o.Limit = 0
|
||||
}
|
||||
|
||||
m := model{
|
||||
index: startingIndex,
|
||||
currentOrder: currentOrder,
|
||||
height: o.Height,
|
||||
padding: []int{top, right, bottom, left},
|
||||
cursor: o.Cursor,
|
||||
header: o.Header,
|
||||
selectedPrefix: o.SelectedPrefix,
|
||||
unselectedPrefix: o.UnselectedPrefix,
|
||||
cursorPrefix: o.CursorPrefix,
|
||||
items: items,
|
||||
limit: o.Limit,
|
||||
paginator: pager,
|
||||
cursorStyle: o.CursorStyle.ToLipgloss(),
|
||||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
itemStyle: o.ItemStyle.ToLipgloss(),
|
||||
selectedItemStyle: o.SelectedItemStyle.ToLipgloss(),
|
||||
numSelected: currentSelected,
|
||||
showHelp: o.ShowHelp,
|
||||
help: help.New(),
|
||||
keymap: km,
|
||||
}
|
||||
if o.Limit > 1 || o.NoLimit {
|
||||
var choices []string
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
field := huh.NewMultiSelect[string]().
|
||||
Options(options...).
|
||||
Title(o.Header).
|
||||
Height(o.Height).
|
||||
Limit(o.Limit).
|
||||
Value(&choices)
|
||||
|
||||
// Disable Keybindings since we will control it ourselves.
|
||||
tm, err := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithContext(ctx),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to pick selection: %w", err)
|
||||
}
|
||||
m = tm.(model)
|
||||
if !m.submitted {
|
||||
return errors.New("nothing selected")
|
||||
}
|
||||
if o.Ordered && o.Limit > 1 {
|
||||
sort.Slice(m.items, func(i, j int) bool {
|
||||
return m.items[i].order < m.items[j].order
|
||||
})
|
||||
}
|
||||
form := huh.NewForm(huh.NewGroup(field))
|
||||
|
||||
var out []string
|
||||
for _, item := range m.items {
|
||||
if item.selected {
|
||||
out = append(out, options[item.text])
|
||||
err := form.
|
||||
WithWidth(width).
|
||||
WithShowHelp(o.ShowHelp).
|
||||
WithTheme(theme).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(choices) > 0 {
|
||||
s := strings.Join(choices, "\n")
|
||||
ansiprint(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tty.Println(strings.Join(out, o.OutputDelimiter))
|
||||
|
||||
var choice string
|
||||
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Options(options...).
|
||||
Title(o.Header).
|
||||
Height(o.Height).
|
||||
Value(&choice),
|
||||
),
|
||||
).
|
||||
WithWidth(width).
|
||||
WithTheme(theme).
|
||||
WithShowHelp(o.ShowHelp).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if term.IsTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(choice)
|
||||
} else {
|
||||
fmt.Print(ansi.Strip(choice))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func widest(options []string) int {
|
||||
var max int
|
||||
for _, o := range options {
|
||||
w := lipgloss.Width(o)
|
||||
if w > max {
|
||||
max = w
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func ansiprint(s string) {
|
||||
if term.IsTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(s)
|
||||
} else {
|
||||
fmt.Print(ansi.Strip(s))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,28 +8,22 @@ import (
|
|||
|
||||
// Options is the customization options for the choose command.
|
||||
type Options struct {
|
||||
Options []string `arg:"" optional:"" help:"Options to choose from."`
|
||||
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
|
||||
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
|
||||
Ordered bool `help:"Maintain the order of the selected options" env:"GUM_CHOOSE_ORDERED"`
|
||||
Height int `help:"Height of the list" default:"10" env:"GUM_CHOOSE_HEIGHT"`
|
||||
Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> " env:"GUM_CHOOSE_CURSOR"`
|
||||
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_CHOOSE_SHOW_HELP"`
|
||||
Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_CHOOSE_TIMEOUT"` // including timeout command options [Timeout,...]
|
||||
Header string `help:"Header value" default:"Choose:" env:"GUM_CHOOSE_HEADER"`
|
||||
CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_CURSOR_PREFIX"`
|
||||
SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"✓ " env:"GUM_CHOOSE_SELECTED_PREFIX"`
|
||||
UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"`
|
||||
Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_CHOOSE_SELECTED"`
|
||||
SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
|
||||
InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"`
|
||||
OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"`
|
||||
LabelDelimiter string `help:"Allows to set a delimiter, so options can be set as label:value" default:"" env:"GUM_CHOOSE_LABEL_DELIMITER"`
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_CHOOSE_STRIP_ANSI"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_CHOOSE_PADDING"`
|
||||
|
||||
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"`
|
||||
ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"`
|
||||
SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"`
|
||||
Options []string `arg:"" optional:"" help:"Options to choose from."`
|
||||
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
|
||||
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
|
||||
Ordered bool `help:"Maintain the order of the selected options" env:"GUM_CHOOSE_ORDERED"`
|
||||
Height int `help:"Height of the list" default:"0" 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,...]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// Package completion provides a bash completion generator for Kong
|
||||
// applications.
|
||||
package completion
|
||||
|
||||
import (
|
||||
|
|
@ -630,7 +628,6 @@ func writeCmdAliases(buf io.StringWriter, cmd *kong.Node) {
|
|||
writeString(buf, ` fi`)
|
||||
writeString(buf, "\n")
|
||||
}
|
||||
|
||||
func writeArgAliases(buf io.StringWriter, cmd *kong.Node) {
|
||||
writeString(buf, " noun_aliases=()\n")
|
||||
sort.Strings(cmd.Aliases)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func (f Fish) gen(buf io.StringWriter, cmd *kong.Node) {
|
|||
_, _ = buf.WriteString(fmt.Sprintf(" -s %c", f.Short))
|
||||
}
|
||||
_, _ = buf.WriteString(fmt.Sprintf(" -l %s", f.Name))
|
||||
_, _ = buf.WriteString(fmt.Sprintf(" -d \"%s\"", f.Help))
|
||||
_, _ = buf.WriteString(fmt.Sprintf(" -d '%s'", f.Help))
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
_, _ = buf.WriteString("\n")
|
||||
|
|
|
|||
|
|
@ -1,71 +1,42 @@
|
|||
package confirm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for prompting a user to confirm an
|
||||
// action with an affirmative or negative answer.
|
||||
func (o Options) Run() error {
|
||||
line, err := stdin.Read(stdin.SingleLine(true))
|
||||
if err == nil {
|
||||
switch line {
|
||||
case "yes", "y":
|
||||
return nil
|
||||
default:
|
||||
return exit.ErrExit(1)
|
||||
}
|
||||
theme := huh.ThemeCharm()
|
||||
theme.Focused.Title = o.PromptStyle.ToLipgloss()
|
||||
theme.Focused.FocusedButton = o.SelectedStyle.ToLipgloss()
|
||||
theme.Focused.BlurredButton = o.UnselectedStyle.ToLipgloss()
|
||||
|
||||
choice := o.Default
|
||||
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Affirmative(o.Affirmative).
|
||||
Negative(o.Negative).
|
||||
Title(o.Prompt).
|
||||
Value(&choice),
|
||||
),
|
||||
).
|
||||
WithTheme(theme).
|
||||
WithShowHelp(o.ShowHelp).
|
||||
Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to run confirm: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
m := model{
|
||||
affirmative: o.Affirmative,
|
||||
negative: o.Negative,
|
||||
showOutput: o.ShowOutput,
|
||||
confirmation: o.Default,
|
||||
defaultSelection: o.Default,
|
||||
keys: defaultKeymap(o.Affirmative, o.Negative),
|
||||
help: help.New(),
|
||||
showHelp: o.ShowHelp,
|
||||
prompt: o.Prompt,
|
||||
selectedStyle: o.SelectedStyle.ToLipgloss(),
|
||||
unselectedStyle: o.UnselectedStyle.ToLipgloss(),
|
||||
promptStyle: o.PromptStyle.ToLipgloss(),
|
||||
padding: []int{top, right, bottom, left},
|
||||
}
|
||||
tm, err := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithContext(ctx),
|
||||
).Run()
|
||||
if err != nil && ctx.Err() != context.DeadlineExceeded {
|
||||
return fmt.Errorf("unable to confirm: %w", err)
|
||||
}
|
||||
m = tm.(model)
|
||||
|
||||
if o.ShowOutput {
|
||||
confirmationText := m.negative
|
||||
if m.confirmation {
|
||||
confirmationText = m.affirmative
|
||||
}
|
||||
fmt.Println(m.prompt, confirmationText)
|
||||
if !choice {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if m.confirmation {
|
||||
return nil
|
||||
}
|
||||
|
||||
return exit.ErrExit(1)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
// Package confirm provides an interface to ask a user to confirm an action.
|
||||
// The user is provided with an interface to choose an affirmative or negative
|
||||
// answer, which is then reflected in the exit code for use in scripting.
|
||||
//
|
||||
// If the user selects the affirmative answer, the program exits with 0. If the
|
||||
// user selects the negative answer, the program exits with 1.
|
||||
//
|
||||
// I.e. confirm if the user wants to delete a file
|
||||
//
|
||||
// $ gum confirm "Are you sure?" && rm file.txt
|
||||
package confirm
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func defaultKeymap(affirmative, negative string) keymap {
|
||||
return keymap{
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "cancel"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
Negative: key.NewBinding(
|
||||
key.WithKeys("n", "N", "q"),
|
||||
key.WithHelp("n", negative),
|
||||
),
|
||||
Affirmative: key.NewBinding(
|
||||
key.WithKeys("y", "Y"),
|
||||
key.WithHelp("y", affirmative),
|
||||
),
|
||||
Toggle: key.NewBinding(
|
||||
key.WithKeys(
|
||||
"left",
|
||||
"h",
|
||||
"ctrl+n",
|
||||
"shift+tab",
|
||||
"right",
|
||||
"l",
|
||||
"ctrl+p",
|
||||
"tab",
|
||||
),
|
||||
key.WithHelp("←→", "toggle"),
|
||||
),
|
||||
Submit: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type keymap struct {
|
||||
Abort key.Binding
|
||||
Quit key.Binding
|
||||
Negative key.Binding
|
||||
Affirmative key.Binding
|
||||
Toggle key.Binding
|
||||
Submit key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Toggle, k.Submit, k.Affirmative, k.Negative}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
prompt string
|
||||
affirmative string
|
||||
negative string
|
||||
quitting bool
|
||||
showHelp bool
|
||||
help help.Model
|
||||
keys keymap
|
||||
|
||||
showOutput bool
|
||||
confirmation bool
|
||||
|
||||
defaultSelection bool
|
||||
|
||||
// styles
|
||||
promptStyle lipgloss.Style
|
||||
selectedStyle lipgloss.Style
|
||||
unselectedStyle lipgloss.Style
|
||||
padding []int
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Abort):
|
||||
m.confirmation = false
|
||||
return m, tea.Interrupt
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.confirmation = false
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Negative):
|
||||
m.confirmation = false
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Toggle):
|
||||
if m.negative == "" {
|
||||
break
|
||||
}
|
||||
m.confirmation = !m.confirmation
|
||||
case key.Matches(msg, m.keys.Submit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Affirmative):
|
||||
m.quitting = true
|
||||
m.confirmation = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
var aff, neg string
|
||||
|
||||
if m.confirmation {
|
||||
aff = m.selectedStyle.Render(m.affirmative)
|
||||
neg = m.unselectedStyle.Render(m.negative)
|
||||
} else {
|
||||
aff = m.unselectedStyle.Render(m.affirmative)
|
||||
neg = m.selectedStyle.Render(m.negative)
|
||||
}
|
||||
|
||||
// If the option is intentionally empty, do not show it.
|
||||
if m.negative == "" {
|
||||
neg = ""
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
m.promptStyle.Render(m.prompt) + "\n",
|
||||
lipgloss.JoinHorizontal(lipgloss.Left, aff, neg),
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
parts = append(parts, "", m.help.View(m.keys))
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
))
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
// Options is the customization options for the confirm command.
|
||||
type Options struct {
|
||||
Default bool `help:"Default confirmation action" default:"true"`
|
||||
ShowOutput bool `help:"Print prompt and chosen action to output" default:"false"`
|
||||
Affirmative string `help:"The title of the affirmative action" default:"Yes"`
|
||||
Negative string `help:"The title of the negative action" default:"No"`
|
||||
Prompt string `arg:"" help:"Prompt to display." default:"Are you sure?"`
|
||||
|
|
@ -20,6 +19,5 @@ type Options struct {
|
|||
//nolint:staticcheck
|
||||
UnselectedStyle style.Styles `embed:"" prefix:"unselected." help:"The style of the unselected action" set:"defaultBackground=235" set:"defaultForeground=254" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_UNSELECTED_"`
|
||||
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_CONFIRM_SHOW_HELP"`
|
||||
Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0s" env:"GUM_CONFIRM_TIMEOUT"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_CONFIRM_PADDING"`
|
||||
Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0" env:"GUM_CONFIRM_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package cursor provides cursor modes.
|
||||
package cursor
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
pkgs.buildGoModule rec {
|
||||
pname = "gum";
|
||||
version = "0.15.2";
|
||||
version = "0.14.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
vendorHash = "sha256-TK2Fc4bTkiSpyYrg4dJOzamEnii03P7kyHZdah9izqY=";
|
||||
vendorHash = "sha256-gDDaKrwlrJyyDzgyGf9iP/XPnOAwpkvIyzCXobXrlF4=";
|
||||
|
||||
ldflags = [ "-s" "-w" "-X=main.Version=${version}" ];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,10 @@ package file
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/filepicker"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Run is the interface to picking a file.
|
||||
|
|
@ -28,52 +24,40 @@ func (o Options) Run() error {
|
|||
return fmt.Errorf("file not found: %w", err)
|
||||
}
|
||||
|
||||
fp := filepicker.New()
|
||||
fp.CurrentDirectory = path
|
||||
fp.Path = path
|
||||
fp.SetHeight(o.Height)
|
||||
fp.AutoHeight = o.Height == 0
|
||||
fp.Cursor = o.Cursor
|
||||
fp.DirAllowed = o.Directory
|
||||
fp.FileAllowed = o.File
|
||||
fp.ShowPermissions = o.Permissions
|
||||
fp.ShowSize = o.Size
|
||||
fp.ShowHidden = o.All
|
||||
fp.Styles = filepicker.DefaultStyles()
|
||||
fp.Styles.Cursor = o.CursorStyle.ToLipgloss()
|
||||
fp.Styles.Symlink = o.SymlinkStyle.ToLipgloss()
|
||||
fp.Styles.Directory = o.DirectoryStyle.ToLipgloss()
|
||||
fp.Styles.File = o.FileStyle.ToLipgloss()
|
||||
fp.Styles.Permission = o.PermissionsStyle.ToLipgloss()
|
||||
fp.Styles.Selected = o.SelectedStyle.ToLipgloss()
|
||||
fp.Styles.FileSize = o.FileSizeStyle.ToLipgloss()
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
m := model{
|
||||
filepicker: fp,
|
||||
padding: []int{top, right, bottom, left},
|
||||
showHelp: o.ShowHelp,
|
||||
help: help.New(),
|
||||
keymap: defaultKeymap(),
|
||||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
header: o.Header,
|
||||
}
|
||||
theme := huh.ThemeCharm()
|
||||
theme.Focused.Base = lipgloss.NewStyle()
|
||||
theme.Focused.File = o.FileStyle.ToLipgloss()
|
||||
theme.Focused.Directory = o.DirectoryStyle.ToLipgloss()
|
||||
theme.Focused.SelectedOption = o.SelectedStyle.ToLipgloss()
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
keymap := huh.NewDefaultKeyMap()
|
||||
keymap.FilePicker.Open.SetEnabled(false)
|
||||
|
||||
// XXX: These should be file selected specific.
|
||||
theme.Focused.TextInput.Placeholder = o.PermissionsStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Prompt = o.CursorStyle.ToLipgloss()
|
||||
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewFilePicker().
|
||||
Picking(true).
|
||||
CurrentDirectory(path).
|
||||
DirAllowed(o.Directory).
|
||||
FileAllowed(o.File).
|
||||
Height(o.Height).
|
||||
ShowHidden(o.All).
|
||||
Value(&path),
|
||||
),
|
||||
).
|
||||
WithShowHelp(o.ShowHelp).
|
||||
WithKeyMap(keymap).
|
||||
WithTheme(theme).
|
||||
Run()
|
||||
|
||||
tm, err := tea.NewProgram(
|
||||
&m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithContext(ctx),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to pick selection: %w", err)
|
||||
}
|
||||
m = tm.(model)
|
||||
if m.selectedPath == "" {
|
||||
return errors.New("no file selected")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(m.selectedPath)
|
||||
fmt.Println(path)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
119
file/file.go
119
file/file.go
|
|
@ -1,119 +0,0 @@
|
|||
// Package file provides an interface to pick a file from a folder (tree).
|
||||
// The user is provided a file manager-like interface to navigate, to
|
||||
// select a file.
|
||||
//
|
||||
// Let's pick a file from the current directory:
|
||||
//
|
||||
// $ gum file
|
||||
// $ gum file .
|
||||
//
|
||||
// Let's pick a file from the home directory:
|
||||
//
|
||||
// $ gum file $HOME
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/filepicker"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type keymap filepicker.KeyMap
|
||||
|
||||
var keyQuit = key.NewBinding(
|
||||
key.WithKeys("esc", "q"),
|
||||
key.WithHelp("esc", "close"),
|
||||
)
|
||||
|
||||
var keyAbort = key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "abort"),
|
||||
)
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
km := filepicker.DefaultKeyMap()
|
||||
return keymap(km)
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("up", "down"),
|
||||
key.WithHelp("↓↑", "navigate"),
|
||||
),
|
||||
keyQuit,
|
||||
k.Select,
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
header string
|
||||
headerStyle lipgloss.Style
|
||||
filepicker filepicker.Model
|
||||
selectedPath string
|
||||
quitting bool
|
||||
showHelp bool
|
||||
padding []int
|
||||
help help.Model
|
||||
keymap keymap
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.filepicker.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
height := msg.Height - m.padding[0] - m.padding[2]
|
||||
if m.showHelp {
|
||||
height -= lipgloss.Height(m.helpView())
|
||||
}
|
||||
m.filepicker.SetHeight(height)
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keyAbort):
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
case key.Matches(msg, keyQuit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.filepicker, cmd = m.filepicker.Update(msg)
|
||||
if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
|
||||
m.selectedPath = path
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if m.header != "" {
|
||||
parts = append(parts, m.headerStyle.Render(m.header))
|
||||
}
|
||||
parts = append(parts, m.filepicker.View())
|
||||
if m.showHelp {
|
||||
parts = append(parts, m.helpView())
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
))
|
||||
}
|
||||
|
||||
func (m model) helpView() string {
|
||||
return m.help.View(m.keymap)
|
||||
}
|
||||
|
|
@ -11,24 +11,21 @@ type Options struct {
|
|||
// Path is the path to the folder / directory to begin traversing.
|
||||
Path string `arg:"" optional:"" name:"path" help:"The path to the folder to begin traversing" env:"GUM_FILE_PATH"`
|
||||
// Cursor is the character to display in front of the current selected items.
|
||||
Cursor string `short:"c" help:"The cursor character" default:">" env:"GUM_FILE_CURSOR"`
|
||||
All bool `short:"a" help:"Show hidden and 'dot' files" default:"false" env:"GUM_FILE_ALL"`
|
||||
Permissions bool `short:"p" help:"Show file permissions" default:"true" negatable:"" env:"GUM_FILE_PERMISSION"`
|
||||
Size bool `short:"s" help:"Show file size" default:"true" negatable:"" env:"GUM_FILE_SIZE"`
|
||||
File bool `help:"Allow files selection" default:"true" env:"GUM_FILE_FILE"`
|
||||
Directory bool `help:"Allow directories selection" default:"false" env:"GUM_FILE_DIRECTORY"`
|
||||
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_FILE_SHOW_HELP"`
|
||||
Timeout time.Duration `help:"Timeout until command aborts without a selection" default:"0s" env:"GUM_FILE_TIMEOUT"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_FILE_HEADER"`
|
||||
Height int `help:"Maximum number of files to display" default:"10" env:"GUM_FILE_HEIGHT"`
|
||||
Cursor string `short:"c" help:"The cursor character" default:">" env:"GUM_FILE_CURSOR"`
|
||||
All bool `short:"a" help:"Show hidden and 'dot' files" default:"false" env:"GUM_FILE_ALL"`
|
||||
File bool `help:"Allow files selection" default:"true" env:"GUM_FILE_FILE"`
|
||||
Directory bool `help:"Allow directories selection" default:"false" env:"GUM_FILE_DIRECTORY"`
|
||||
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_FILE_SHOW_HELP"`
|
||||
|
||||
Height int `help:"Maximum number of files to display" default:"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_"`
|
||||
FileStyle style.Styles `embed:"" prefix:"file." help:"The style to use for files" envprefix:"GUM_FILE_FILE_"`
|
||||
PermissionsStyle style.Styles `embed:"" prefix:"permissions." help:"The style to use for permissions" set:"defaultForeground=244" envprefix:"GUM_FILE_PERMISSIONS_"`
|
||||
SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style to use for the selected item" set:"defaultBold=true" set:"defaultForeground=212" envprefix:"GUM_FILE_SELECTED_"` //nolint:staticcheck
|
||||
FileSizeStyle style.Styles `embed:"" prefix:"file-size." help:"The style to use for file sizes" set:"defaultWidth=8" set:"defaultAlign=right" set:"defaultForeground=240" envprefix:"GUM_FILE_FILE_SIZE_"` //nolint:staticcheck
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILE_HEADER_"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_FILE_PADDING"`
|
||||
//nolint:staticcheck
|
||||
SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style to use for the selected item" set:"defaultBold=true" set:"defaultForeground=212" envprefix:"GUM_FILE_SELECTED_"`
|
||||
//nolint:staticcheck
|
||||
FileSizeStyle style.Styles `embed:"" prefix:"file-size." help:"The style to use for file sizes" set:"defaultWidth=8" set:"defaultAlign=right" set:"defaultForeground=240" envprefix:"GUM_FILE_FILE_SIZE_"`
|
||||
Timeout time.Duration `help:"Timeout until command aborts without a selection" default:"0" env:"GUM_FILE_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,18 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/sahilm/fuzzy"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/internal/files"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/internal/tty"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for filtering through options, powered
|
||||
|
|
@ -35,8 +33,8 @@ func (o Options) Run() error {
|
|||
v := viewport.New(o.Width, o.Height)
|
||||
|
||||
if len(o.Options) == 0 {
|
||||
if input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); input != "" {
|
||||
o.Options = strings.Split(input, o.InputDelimiter)
|
||||
if input, _ := stdin.Read(); input != "" {
|
||||
o.Options = strings.Split(input, "\n")
|
||||
} else {
|
||||
o.Options = files.List()
|
||||
}
|
||||
|
|
@ -46,14 +44,12 @@ func (o Options) Run() error {
|
|||
return errors.New("no options provided, see `gum filter --help`")
|
||||
}
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
options := []tea.ProgramOption{
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithReportFocus(),
|
||||
tea.WithContext(ctx),
|
||||
if o.SelectIfOne && len(o.Options) == 1 {
|
||||
fmt.Println(o.Options[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
options := []tea.ProgramOption{tea.WithOutput(os.Stderr)}
|
||||
if o.Height == 0 {
|
||||
options = append(options, tea.WithAltScreen())
|
||||
}
|
||||
|
|
@ -62,43 +58,21 @@ func (o Options) Run() error {
|
|||
if o.Value != "" {
|
||||
i.SetValue(o.Value)
|
||||
}
|
||||
|
||||
choices := map[string]string{}
|
||||
filteringChoices := []string{}
|
||||
for _, opt := range o.Options {
|
||||
s := ansi.Strip(opt)
|
||||
choices[s] = opt
|
||||
filteringChoices = append(filteringChoices, s)
|
||||
}
|
||||
switch {
|
||||
case o.Value != "" && o.Fuzzy:
|
||||
matches = fuzzy.Find(o.Value, filteringChoices)
|
||||
matches = fuzzy.Find(o.Value, o.Options)
|
||||
case o.Value != "" && !o.Fuzzy:
|
||||
matches = exactMatches(o.Value, filteringChoices)
|
||||
matches = exactMatches(o.Value, o.Options)
|
||||
default:
|
||||
matches = matchAll(filteringChoices)
|
||||
matches = matchAll(o.Options)
|
||||
}
|
||||
|
||||
if o.NoLimit {
|
||||
o.Limit = len(o.Options)
|
||||
}
|
||||
|
||||
if o.SelectIfOne && len(matches) == 1 {
|
||||
tty.Println(matches[0].Str)
|
||||
return nil
|
||||
}
|
||||
|
||||
km := defaultKeymap()
|
||||
if o.NoLimit || o.Limit > 1 {
|
||||
km.Toggle.SetEnabled(true)
|
||||
km.ToggleAndPrevious.SetEnabled(true)
|
||||
km.ToggleAndNext.SetEnabled(true)
|
||||
km.ToggleAll.SetEnabled(true)
|
||||
}
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
m := model{
|
||||
choices: choices,
|
||||
filteringChoices: filteringChoices,
|
||||
p := tea.NewProgram(model{
|
||||
choices: o.Options,
|
||||
indicator: o.Indicator,
|
||||
matches: matches,
|
||||
header: o.Header,
|
||||
|
|
@ -114,61 +88,51 @@ func (o Options) Run() error {
|
|||
textStyle: o.TextStyle.ToLipgloss(),
|
||||
cursorTextStyle: o.CursorTextStyle.ToLipgloss(),
|
||||
height: o.Height,
|
||||
padding: []int{top, right, bottom, left},
|
||||
selected: make(map[string]struct{}),
|
||||
limit: o.Limit,
|
||||
reverse: o.Reverse,
|
||||
fuzzy: o.Fuzzy,
|
||||
sort: o.Sort && o.FuzzySort,
|
||||
strict: o.Strict,
|
||||
showHelp: o.ShowHelp,
|
||||
keymap: km,
|
||||
help: help.New(),
|
||||
}
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
sort: o.Sort,
|
||||
}, options...)
|
||||
|
||||
isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*"
|
||||
currentSelected := 0
|
||||
if len(o.Selected) > 0 {
|
||||
for i, option := range matches {
|
||||
if currentSelected >= o.Limit || (!isSelectAll && !slices.Contains(o.Selected, option.Str)) {
|
||||
continue
|
||||
}
|
||||
if o.Limit == 1 {
|
||||
m.cursor = i
|
||||
m.selected[option.Str] = struct{}{}
|
||||
} else {
|
||||
currentSelected++
|
||||
m.selected[option.Str] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tm, err := tea.NewProgram(m, options...).Run()
|
||||
tm, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to run filter: %w", err)
|
||||
}
|
||||
|
||||
m = tm.(model)
|
||||
if !m.submitted {
|
||||
return errors.New("nothing selected")
|
||||
m := tm.(model)
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
|
||||
isTTY := term.IsTerminal(os.Stdout.Fd())
|
||||
|
||||
// allSelections contains values only if limit is greater
|
||||
// than 1 or if flag --no-limit is passed, hence there is
|
||||
// no need to further checks
|
||||
if len(m.selected) > 0 {
|
||||
o.checkSelected(m)
|
||||
o.checkSelected(m, isTTY)
|
||||
} else if len(m.matches) > m.cursor && m.cursor >= 0 {
|
||||
tty.Println(m.matches[m.cursor].Str)
|
||||
if isTTY {
|
||||
fmt.Println(m.matches[m.cursor].Str)
|
||||
} else {
|
||||
fmt.Println(ansi.Strip(m.matches[m.cursor].Str))
|
||||
}
|
||||
}
|
||||
|
||||
if !o.Strict && len(m.textinput.Value()) != 0 && len(m.matches) == 0 {
|
||||
fmt.Println(m.textinput.Value())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o Options) checkSelected(m model) {
|
||||
out := []string{}
|
||||
func (o Options) checkSelected(m model, isTTY bool) {
|
||||
for k := range m.selected {
|
||||
out = append(out, k)
|
||||
if isTTY {
|
||||
fmt.Println(k)
|
||||
} else {
|
||||
fmt.Println(ansi.Strip(k))
|
||||
}
|
||||
}
|
||||
tty.Println(strings.Join(out, o.OutputDelimiter))
|
||||
}
|
||||
|
|
|
|||
369
filter/filter.go
369
filter/filter.go
|
|
@ -12,122 +12,21 @@ package filter
|
|||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/gum/timeout"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/exp/ordered"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
return keymap{
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+j", "ctrl+n"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+k", "ctrl+p"),
|
||||
),
|
||||
NDown: key.NewBinding(
|
||||
key.WithKeys("j"),
|
||||
),
|
||||
NUp: key.NewBinding(
|
||||
key.WithKeys("k"),
|
||||
),
|
||||
Home: key.NewBinding(
|
||||
key.WithKeys("g", "home"),
|
||||
),
|
||||
End: key.NewBinding(
|
||||
key.WithKeys("G", "end"),
|
||||
),
|
||||
ToggleAndNext: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "toggle"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
ToggleAndPrevious: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "toggle"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
Toggle: key.NewBinding(
|
||||
key.WithKeys("ctrl+@"),
|
||||
key.WithHelp("ctrl+@", "toggle"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
ToggleAll: key.NewBinding(
|
||||
key.WithKeys("ctrl+a"),
|
||||
key.WithHelp("ctrl+a", "select all"),
|
||||
key.WithDisabled(),
|
||||
),
|
||||
FocusInSearch: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "search"),
|
||||
),
|
||||
FocusOutSearch: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "blur search"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "abort"),
|
||||
),
|
||||
Submit: key.NewBinding(
|
||||
key.WithKeys("enter", "ctrl+q"),
|
||||
key.WithHelp("enter", "submit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type keymap struct {
|
||||
FocusInSearch,
|
||||
FocusOutSearch,
|
||||
Down,
|
||||
Up,
|
||||
NDown,
|
||||
NUp,
|
||||
Home,
|
||||
End,
|
||||
ToggleAndNext,
|
||||
ToggleAndPrevious,
|
||||
ToggleAll,
|
||||
Toggle,
|
||||
Abort,
|
||||
Quit,
|
||||
Submit key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("up", "down"),
|
||||
key.WithHelp("↓↑", "navigate"),
|
||||
),
|
||||
k.FocusInSearch,
|
||||
k.FocusOutSearch,
|
||||
k.ToggleAndNext,
|
||||
k.ToggleAll,
|
||||
k.Submit,
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
textinput textinput.Model
|
||||
viewport *viewport.Model
|
||||
choices map[string]string
|
||||
filteringChoices []string
|
||||
choices []string
|
||||
matches []fuzzy.Match
|
||||
cursor int
|
||||
header string
|
||||
|
|
@ -138,7 +37,7 @@ type model struct {
|
|||
selectedPrefix string
|
||||
unselectedPrefix string
|
||||
height int
|
||||
padding []int
|
||||
aborted bool
|
||||
quitting bool
|
||||
headerStyle lipgloss.Style
|
||||
matchStyle lipgloss.Style
|
||||
|
|
@ -150,15 +49,13 @@ type model struct {
|
|||
reverse bool
|
||||
fuzzy bool
|
||||
sort bool
|
||||
showHelp bool
|
||||
keymap keymap
|
||||
help help.Model
|
||||
strict bool
|
||||
submitted bool
|
||||
timeout time.Duration
|
||||
hasTimeout bool
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return timeout.Init(m.timeout, nil)
|
||||
}
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
|
|
@ -205,24 +102,30 @@ func (m model) View() string {
|
|||
s.WriteString(" ")
|
||||
}
|
||||
|
||||
styledOption := m.choices[match.Str]
|
||||
if len(match.MatchedIndexes) == 0 {
|
||||
// No matches, just render the text.
|
||||
s.WriteString(lineTextStyle.Render(styledOption))
|
||||
s.WriteRune('\n')
|
||||
continue
|
||||
}
|
||||
// For this match, there are a certain number of characters that have
|
||||
// caused the match. i.e. fuzzy matching.
|
||||
// We should indicate to the users which characters are being matched.
|
||||
mi := 0
|
||||
var buf strings.Builder
|
||||
for ci, c := range match.Str {
|
||||
// Check if the current character index matches the current matched
|
||||
// index. If so, color the character to indicate a match.
|
||||
if mi < len(match.MatchedIndexes) && ci == match.MatchedIndexes[mi] {
|
||||
// Flush text buffer.
|
||||
s.WriteString(lineTextStyle.Render(buf.String()))
|
||||
buf.Reset()
|
||||
|
||||
var ranges []lipgloss.Range
|
||||
for _, rng := range matchedRanges(match.MatchedIndexes) {
|
||||
// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
|
||||
// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
|
||||
// so we need to adjust it here:
|
||||
start, stop := bytePosToVisibleCharPos(match.Str, rng)
|
||||
ranges = append(ranges, lipgloss.NewRange(start, stop+1, m.matchStyle))
|
||||
s.WriteString(m.matchStyle.Render(string(c)))
|
||||
// We have matched this character, so we never have to check it
|
||||
// again. Move on to the next match.
|
||||
mi++
|
||||
} else {
|
||||
// Not a match, buffer a regular character.
|
||||
buf.WriteRune(c)
|
||||
}
|
||||
}
|
||||
|
||||
s.WriteString(lineTextStyle.Render(lipgloss.StyleRanges(styledOption, ranges...)))
|
||||
// Flush text buffer.
|
||||
s.WriteString(lineTextStyle.Render(buf.String()))
|
||||
|
||||
// We have finished displaying the match with all of it's matched
|
||||
// characters highlighted and the rest filled in.
|
||||
|
|
@ -235,114 +138,79 @@ func (m model) View() string {
|
|||
// View the input and the filtered choices
|
||||
header := m.headerStyle.Render(m.header)
|
||||
if m.reverse {
|
||||
view := m.viewport.View()
|
||||
view := m.viewport.View() + "\n" + m.textinput.View()
|
||||
if m.header != "" {
|
||||
view += "\n" + header
|
||||
return lipgloss.JoinVertical(lipgloss.Left, view, header)
|
||||
}
|
||||
view += "\n" + m.textinput.View()
|
||||
if m.showHelp {
|
||||
view += m.helpView()
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(view)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
view := m.textinput.View() + "\n" + m.viewport.View()
|
||||
if m.showHelp {
|
||||
view += m.helpView()
|
||||
}
|
||||
if m.header != "" {
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(header + "\n" + view)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, view)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(view)
|
||||
}
|
||||
|
||||
func (m model) helpView() string {
|
||||
return "\n\n" + m.help.View(m.keymap)
|
||||
return view
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd, icmd tea.Cmd
|
||||
m.textinput, icmd = m.textinput.Update(msg)
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case timeout.TickTimeoutMsg:
|
||||
if msg.TimeoutValue <= 0 {
|
||||
m.quitting = true
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.timeout = msg.TimeoutValue
|
||||
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
if m.height == 0 || m.height > msg.Height {
|
||||
m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View())
|
||||
}
|
||||
// Include the header in the height calculation.
|
||||
|
||||
// Make place in the view port if header is set
|
||||
if m.header != "" {
|
||||
m.viewport.Height = m.viewport.Height - lipgloss.Height(m.headerStyle.Render(m.header))
|
||||
}
|
||||
// Include the help in the total height calculation.
|
||||
if m.showHelp {
|
||||
m.viewport.Height = m.viewport.Height - lipgloss.Height(m.helpView())
|
||||
}
|
||||
m.viewport.Height = m.viewport.Height - m.padding[0] - m.padding[2]
|
||||
m.viewport.Width = msg.Width - m.padding[1] - m.padding[3]
|
||||
m.textinput.Width = msg.Width - m.padding[1] - m.padding[3]
|
||||
m.viewport.Width = msg.Width
|
||||
if m.reverse {
|
||||
m.viewport.YOffset = ordered.Clamp(len(m.matches)-m.viewport.Height, 0, len(m.matches))
|
||||
m.viewport.YOffset = clamp(0, len(m.matches), len(m.matches)-m.viewport.Height)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
km := m.keymap
|
||||
switch {
|
||||
case key.Matches(msg, km.FocusInSearch):
|
||||
m.textinput.Focus()
|
||||
case key.Matches(msg, km.FocusOutSearch):
|
||||
m.textinput.Blur()
|
||||
case key.Matches(msg, km.Quit):
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.aborted = true
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Abort):
|
||||
case "enter":
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
case key.Matches(msg, km.Submit):
|
||||
m.quitting = true
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Down, km.NDown):
|
||||
case "ctrl+n", "ctrl+j", "down":
|
||||
m.CursorDown()
|
||||
case key.Matches(msg, km.Up, km.NUp):
|
||||
case "ctrl+p", "ctrl+k", "up":
|
||||
m.CursorUp()
|
||||
case key.Matches(msg, km.Home):
|
||||
m.cursor = 0
|
||||
m.viewport.GotoTop()
|
||||
case key.Matches(msg, km.End):
|
||||
m.cursor = len(m.choices) - 1
|
||||
m.viewport.GotoBottom()
|
||||
case key.Matches(msg, km.ToggleAndNext):
|
||||
case "tab":
|
||||
if m.limit == 1 {
|
||||
break // no op
|
||||
}
|
||||
m.ToggleSelection()
|
||||
m.CursorDown()
|
||||
case key.Matches(msg, km.ToggleAndPrevious):
|
||||
case "shift+tab":
|
||||
if m.limit == 1 {
|
||||
break // no op
|
||||
}
|
||||
m.ToggleSelection()
|
||||
m.CursorUp()
|
||||
case key.Matches(msg, km.Toggle):
|
||||
case "ctrl+@":
|
||||
if m.limit == 1 {
|
||||
break // no op
|
||||
}
|
||||
m.ToggleSelection()
|
||||
case key.Matches(msg, km.ToggleAll):
|
||||
if m.limit <= 1 {
|
||||
break
|
||||
}
|
||||
if m.numSelected < len(m.matches) && m.numSelected < m.limit {
|
||||
m = m.selectAll()
|
||||
} else {
|
||||
m = m.deselectAll()
|
||||
}
|
||||
default:
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
|
||||
// yOffsetFromBottom is the number of lines from the bottom of the
|
||||
// list to the top of the viewport. This is used to keep the viewport
|
||||
// at a constant position when the number of matches are reduced
|
||||
|
|
@ -354,57 +222,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
// A character was entered, this likely means that the text input has
|
||||
// changed. This suggests that the matches are outdated, so update them.
|
||||
var choices []string
|
||||
if !m.strict {
|
||||
choices = append(choices, m.textinput.Value())
|
||||
}
|
||||
choices = append(choices, m.filteringChoices...)
|
||||
if m.fuzzy {
|
||||
if m.sort {
|
||||
m.matches = fuzzy.Find(m.textinput.Value(), choices)
|
||||
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
|
||||
} else {
|
||||
m.matches = fuzzy.FindNoSort(m.textinput.Value(), choices)
|
||||
m.matches = fuzzy.FindNoSort(m.textinput.Value(), m.choices)
|
||||
}
|
||||
} else {
|
||||
m.matches = exactMatches(m.textinput.Value(), choices)
|
||||
m.matches = exactMatches(m.textinput.Value(), m.choices)
|
||||
}
|
||||
|
||||
// If the search field is empty, let's not display the matches
|
||||
// (none), but rather display all possible choices.
|
||||
if m.textinput.Value() == "" {
|
||||
m.matches = matchAll(m.filteringChoices)
|
||||
m.matches = matchAll(m.choices)
|
||||
}
|
||||
|
||||
// For reverse layout, we need to offset the viewport so that the
|
||||
// it remains at a constant position relative to the cursor.
|
||||
if m.reverse {
|
||||
maxYOffset := max(0, len(m.matches)-m.viewport.Height)
|
||||
m.viewport.YOffset = ordered.Clamp(len(m.matches)-yOffsetFromBottom, 0, maxYOffset)
|
||||
m.viewport.YOffset = clamp(0, maxYOffset, len(m.matches)-yOffsetFromBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.keymap.FocusInSearch.SetEnabled(!m.textinput.Focused())
|
||||
m.keymap.FocusOutSearch.SetEnabled(m.textinput.Focused())
|
||||
m.keymap.NUp.SetEnabled(!m.textinput.Focused())
|
||||
m.keymap.NDown.SetEnabled(!m.textinput.Focused())
|
||||
m.keymap.Home.SetEnabled(!m.textinput.Focused())
|
||||
m.keymap.End.SetEnabled(!m.textinput.Focused())
|
||||
|
||||
// It's possible that filtering items have caused fewer matches. So, ensure
|
||||
// that the selected index is within the bounds of the number of matches.
|
||||
m.cursor = ordered.Clamp(m.cursor, 0, len(m.matches)-1)
|
||||
return m, tea.Batch(cmd, icmd)
|
||||
m.cursor = clamp(0, len(m.matches)-1, m.cursor)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *model) CursorUp() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
if m.reverse { //nolint:nestif
|
||||
if m.reverse {
|
||||
m.cursor = (m.cursor + 1) % len(m.matches)
|
||||
if len(m.matches)-m.cursor <= m.viewport.YOffset {
|
||||
m.viewport.ScrollUp(1)
|
||||
m.viewport.LineUp(1)
|
||||
}
|
||||
if len(m.matches)-m.cursor > m.viewport.Height+m.viewport.YOffset {
|
||||
m.viewport.SetYOffset(len(m.matches) - m.viewport.Height)
|
||||
|
|
@ -412,7 +265,7 @@ func (m *model) CursorUp() {
|
|||
} else {
|
||||
m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches)
|
||||
if m.cursor < m.viewport.YOffset {
|
||||
m.viewport.ScrollUp(1)
|
||||
m.viewport.LineUp(1)
|
||||
}
|
||||
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
||||
m.viewport.SetYOffset(len(m.matches) - m.viewport.Height)
|
||||
|
|
@ -421,13 +274,10 @@ func (m *model) CursorUp() {
|
|||
}
|
||||
|
||||
func (m *model) CursorDown() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
if m.reverse { //nolint:nestif
|
||||
if m.reverse {
|
||||
m.cursor = (m.cursor - 1 + len(m.matches)) % len(m.matches)
|
||||
if len(m.matches)-m.cursor > m.viewport.Height+m.viewport.YOffset {
|
||||
m.viewport.ScrollDown(1)
|
||||
m.viewport.LineDown(1)
|
||||
}
|
||||
if len(m.matches)-m.cursor <= m.viewport.YOffset {
|
||||
m.viewport.GotoTop()
|
||||
|
|
@ -435,7 +285,7 @@ func (m *model) CursorDown() {
|
|||
} else {
|
||||
m.cursor = (m.cursor + 1) % len(m.matches)
|
||||
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
||||
m.viewport.ScrollDown(1)
|
||||
m.viewport.LineDown(1)
|
||||
}
|
||||
if m.cursor < m.viewport.YOffset {
|
||||
m.viewport.GotoTop()
|
||||
|
|
@ -453,26 +303,6 @@ func (m *model) ToggleSelection() {
|
|||
}
|
||||
}
|
||||
|
||||
func (m model) selectAll() model {
|
||||
for i := range m.matches {
|
||||
if m.numSelected >= m.limit {
|
||||
break // do not exceed given limit
|
||||
}
|
||||
if _, ok := m.selected[m.matches[i].Str]; ok {
|
||||
continue
|
||||
}
|
||||
m.selected[m.matches[i].Str] = struct{}{}
|
||||
m.numSelected++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) deselectAll() model {
|
||||
m.selected = make(map[string]struct{})
|
||||
m.numSelected = 0
|
||||
return m
|
||||
}
|
||||
|
||||
func matchAll(options []string) []fuzzy.Match {
|
||||
matches := make([]fuzzy.Match, len(options))
|
||||
for i, option := range options {
|
||||
|
|
@ -504,46 +334,13 @@ func exactMatches(search string, choices []string) []fuzzy.Match {
|
|||
return matches
|
||||
}
|
||||
|
||||
func matchedRanges(in []int) [][2]int {
|
||||
if len(in) == 0 {
|
||||
return [][2]int{}
|
||||
//nolint:unparam
|
||||
func clamp(min, max, val int) int {
|
||||
if val < min {
|
||||
return min
|
||||
}
|
||||
current := [2]int{in[0], in[0]}
|
||||
if len(in) == 1 {
|
||||
return [][2]int{current}
|
||||
if val > max {
|
||||
return max
|
||||
}
|
||||
var out [][2]int
|
||||
for i := 1; i < len(in); i++ {
|
||||
if in[i] == current[1]+1 {
|
||||
current[1] = in[i]
|
||||
} else {
|
||||
out = append(out, current)
|
||||
current = [2]int{in[i], in[i]}
|
||||
}
|
||||
}
|
||||
out = append(out, current)
|
||||
return out
|
||||
}
|
||||
|
||||
func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
|
||||
bytePos, byteStart, byteStop := 0, rng[0], rng[1]
|
||||
pos, start, stop := 0, 0, 0
|
||||
gr := uniseg.NewGraphemes(str)
|
||||
for byteStart > bytePos {
|
||||
if !gr.Next() {
|
||||
break
|
||||
}
|
||||
bytePos += len(gr.Str())
|
||||
pos += max(1, gr.Width())
|
||||
}
|
||||
start = pos
|
||||
for byteStop > bytePos {
|
||||
if !gr.Next() {
|
||||
break
|
||||
}
|
||||
bytePos += len(gr.Str())
|
||||
pos += max(1, gr.Width())
|
||||
}
|
||||
stop = pos
|
||||
return start, stop
|
||||
return val
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestMatchedRanges(t *testing.T) {
|
||||
for name, tt := range map[string]struct {
|
||||
in []int
|
||||
out [][2]int
|
||||
}{
|
||||
"empty": {
|
||||
in: []int{},
|
||||
out: [][2]int{},
|
||||
},
|
||||
"one char": {
|
||||
in: []int{1},
|
||||
out: [][2]int{{1, 1}},
|
||||
},
|
||||
"2 char range": {
|
||||
in: []int{1, 2},
|
||||
out: [][2]int{{1, 2}},
|
||||
},
|
||||
"multiple char range": {
|
||||
in: []int{1, 2, 3, 4, 5, 6},
|
||||
out: [][2]int{{1, 6}},
|
||||
},
|
||||
"multiple char ranges": {
|
||||
in: []int{1, 2, 3, 5, 6, 10, 11, 12, 13, 23, 24, 40, 42, 43, 45, 52},
|
||||
out: [][2]int{{1, 3}, {5, 6}, {10, 13}, {23, 24}, {40, 40}, {42, 43}, {45, 45}, {52, 52}},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
match := matchedRanges(tt.in)
|
||||
if !reflect.DeepEqual(match, tt.out) {
|
||||
t.Errorf("expected %v, got %v", tt.out, match)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteToChar(t *testing.T) {
|
||||
stStr := "\x1b[90m\ue615\x1b[39m \x1b[3m\x1b[32mDow\x1b[0m\x1b[90m\x1b[39m\x1b[3wnloads"
|
||||
str := " Downloads"
|
||||
rng := [2]int{4, 7}
|
||||
expect := "Dow"
|
||||
|
||||
if got := str[rng[0]:rng[1]]; got != expect {
|
||||
t.Errorf("expected %q, got %q", expect, got)
|
||||
}
|
||||
|
||||
start, stop := bytePosToVisibleCharPos(str, rng)
|
||||
if got := ansi.Strip(ansi.Cut(stStr, start, stop)); got != expect {
|
||||
t.Errorf("expected %+q, got %+q", expect, got)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,14 +15,12 @@ type Options struct {
|
|||
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
|
||||
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
|
||||
SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"`
|
||||
Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_FILTER_SELECTED"`
|
||||
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_FILTER_SHOW_HELP"`
|
||||
Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"" default:"true" group:"Selection"`
|
||||
Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" default:"true" group:"Selection"`
|
||||
SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"`
|
||||
SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"`
|
||||
UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"`
|
||||
UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILTER_HEADER_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_FILTER_HEADER_"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_FILTER_HEADER"`
|
||||
TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"`
|
||||
CursorTextStyle style.Styles `embed:"" prefix:"cursor-text." envprefix:"GUM_FILTER_CURSOR_TEXT_"`
|
||||
|
|
@ -35,14 +33,7 @@ type Options struct {
|
|||
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"`
|
||||
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"`
|
||||
Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"`
|
||||
Fuzzy bool `help:"Enable fuzzy matching; otherwise match from start of word" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
|
||||
FuzzySort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:""`
|
||||
Timeout time.Duration `help:"Timeout until filter command aborts" default:"0s" env:"GUM_FILTER_TIMEOUT"`
|
||||
InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_FILTER_INPUT_DELIMITER"`
|
||||
OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_FILTER_OUTPUT_DELIMITER"`
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FILTER_STRIP_ANSI"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_FILTER_PADDING"`
|
||||
|
||||
// Deprecated: use [FuzzySort]. This will be removed at some point.
|
||||
Sort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:"" hidden:""`
|
||||
Fuzzy bool `help:"Enable fuzzy matching" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
|
||||
Sort bool `help:"Sort the results" default:"true" env:"GUM_FILTER_SORT" negatable:""`
|
||||
Timeout time.Duration `help:"Timeout until filter command aborts" default:"0" env:"GUM_FILTER_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -5,11 +5,11 @@
|
|||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -20,11 +20,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1737062831,
|
||||
"narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=",
|
||||
"lastModified": 1715447595,
|
||||
"narHash": "sha256-VsVAUQOj/cS1LCOmMjAGeRksXIAdPnFIjCQ0XLkCsT0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c",
|
||||
"rev": "062ca2a9370a27a35c524dc82d540e6e9824b652",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Four different parse-able formats exist:
|
|||
1. [Markdown](#markdown)
|
||||
2. [Code](#code)
|
||||
3. [Template](#template)
|
||||
4. [Emoji](#emoji)
|
||||
3. [Emoji](#emoji)
|
||||
|
||||
## Markdown
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ Render styled input from a string template. Templating is handled by
|
|||
```bash
|
||||
gum format --type template '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}'
|
||||
# Or, via stdin
|
||||
echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' | gum format --type template
|
||||
echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' | gum format --type template
|
||||
```
|
||||
|
||||
## Emoji
|
||||
|
|
@ -55,30 +55,5 @@ Emoji](https://github.com/yuin/goldmark-emoji)
|
|||
```bash
|
||||
gum format --type emoji 'I :heart: Bubble Gum :candy:'
|
||||
# You know the drill, also via stdin
|
||||
echo 'I :heart: Bubble Gum :candy:' | gum format --type emoji
|
||||
echo 'I :heart: Bubble Gum :candy:' | gum format --type emoji
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
Tables are rendered using [Glamour](https://github.com/charmbracelet/glamour).
|
||||
|
||||
| Bubble Gum Flavor | Price |
|
||||
| ----------------- | ----- |
|
||||
| Strawberry | $0.99 |
|
||||
| Cherry | $0.50 |
|
||||
| Banana | $0.75 |
|
||||
| Orange | $0.25 |
|
||||
| Lemon | $0.50 |
|
||||
| Lime | $0.50 |
|
||||
| Grape | $0.50 |
|
||||
| Watermelon | $0.50 |
|
||||
| Pineapple | $0.50 |
|
||||
| Blueberry | $0.50 |
|
||||
| Raspberry | $0.50 |
|
||||
| Cranberry | $0.50 |
|
||||
| Peach | $0.50 |
|
||||
| Apple | $0.50 |
|
||||
| Mango | $0.50 |
|
||||
| Pomegranate | $0.50 |
|
||||
| Coconut | $0.50 |
|
||||
| Cinnamon | $0.50 |
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func (o Options) Run() error {
|
|||
if len(o.Template) > 0 {
|
||||
input = strings.Join(o.Template, "\n")
|
||||
} else {
|
||||
input, _ = stdin.Read(stdin.StripANSI(o.StripANSI))
|
||||
input, _ = stdin.Read()
|
||||
}
|
||||
|
||||
switch o.Type {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,5 @@ type Options struct {
|
|||
Theme string `help:"Glamour theme to use for markdown formatting" default:"pink" env:"GUM_FORMAT_THEME"`
|
||||
Language string `help:"Programming language to parse code" short:"l" default:"" env:"GUM_FORMAT_LANGUAGE"`
|
||||
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FORMAT_STRIP_ANSI"`
|
||||
|
||||
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"`
|
||||
}
|
||||
|
|
|
|||
60
go.mod
60
go.mod
|
|
@ -1,26 +1,22 @@
|
|||
module github.com/charmbracelet/gum
|
||||
|
||||
go 1.24.2
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/alecthomas/kong v1.14.0
|
||||
github.com/alecthomas/kong v0.9.0
|
||||
github.com/alecthomas/mango-kong v0.1.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/charmbracelet/x/editor v0.2.0
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/charmbracelet/x/xpty v0.1.3
|
||||
github.com/charmbracelet/bubbles v0.19.0
|
||||
github.com/charmbracelet/bubbletea v0.27.0
|
||||
github.com/charmbracelet/glamour v0.8.0
|
||||
github.com/charmbracelet/huh v0.5.3
|
||||
github.com/charmbracelet/lipgloss v0.13.0
|
||||
github.com/charmbracelet/log v0.4.0
|
||||
github.com/charmbracelet/x/ansi v0.2.2
|
||||
github.com/charmbracelet/x/term v0.2.0
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/roff v0.1.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -28,35 +24,29 @@ require (
|
|||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/yuin/goldmark v1.7.4 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/term v0.22.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
)
|
||||
|
|
|
|||
128
go.sum
128
go.sum
|
|
@ -1,67 +1,45 @@
|
|||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s=
|
||||
github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
|
||||
github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
|
||||
github.com/alecthomas/mango-kong v0.1.0 h1:iFVfP1k1K4qpml3JUQmD5I8MCQYfIvsD9mRdrw7jJC4=
|
||||
github.com/alecthomas/mango-kong v0.1.0/go.mod h1:t+TYVdsONUolf/BwVcm+15eqcdAj15h4Qe9MMFAwwT4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
|
||||
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0=
|
||||
github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA=
|
||||
github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU=
|
||||
github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y=
|
||||
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
|
||||
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
|
||||
github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg=
|
||||
github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ=
|
||||
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||
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.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0=
|
||||
github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
|
|
@ -78,17 +56,19 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
|||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
|
|
@ -99,8 +79,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
|
|
@ -109,26 +89,26 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/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/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/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
13
gum.go
13
gum.go
|
|
@ -17,7 +17,6 @@ import (
|
|||
"github.com/charmbracelet/gum/spin"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/gum/table"
|
||||
"github.com/charmbracelet/gum/version"
|
||||
"github.com/charmbracelet/gum/write"
|
||||
)
|
||||
|
||||
|
|
@ -134,7 +133,7 @@ type Gum struct {
|
|||
// │ 7 │ │
|
||||
// │ 8 │ │
|
||||
// ╰────────────────────────────────────────────────╯
|
||||
// ↓↑: navigate • q: quit
|
||||
// ↑/↓: Navigate • q: Quit
|
||||
//
|
||||
Pager pager.Options `cmd:"" help:"Scroll through a file"`
|
||||
|
||||
|
|
@ -215,14 +214,4 @@ type Gum struct {
|
|||
// $ gum log --level info "Hello, world!"
|
||||
//
|
||||
Log log.Options `cmd:"" help:"Log messages to output"`
|
||||
|
||||
// VersionCheck provides a command that checks if the current gum version
|
||||
// matches a given semantic version constraint.
|
||||
//
|
||||
// It can be used to check that a minimum gum version is installed in a
|
||||
// script.
|
||||
//
|
||||
// $ gum version-check '~> 0.15'
|
||||
//
|
||||
VersionCheck version.Options `cmd:"" help:"Semver check current gum version"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,65 @@
|
|||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/cursor"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for the text input bubble.
|
||||
// https://github.com/charmbracelet/bubbles/textinput
|
||||
func (o Options) Run() error {
|
||||
if o.Value == "" {
|
||||
if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" {
|
||||
o.Value = in
|
||||
}
|
||||
}
|
||||
|
||||
i := textinput.New()
|
||||
var value string
|
||||
if o.Value != "" {
|
||||
i.SetValue(o.Value)
|
||||
} else if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" {
|
||||
i.SetValue(in)
|
||||
value = o.Value
|
||||
} else if in, _ := stdin.Read(); in != "" {
|
||||
value = in
|
||||
}
|
||||
i.Focus()
|
||||
i.Prompt = o.Prompt
|
||||
i.Placeholder = o.Placeholder
|
||||
i.Width = o.Width
|
||||
i.PromptStyle = o.PromptStyle.ToLipgloss()
|
||||
i.PlaceholderStyle = o.PlaceholderStyle.ToLipgloss()
|
||||
i.Cursor.Style = o.CursorStyle.ToLipgloss()
|
||||
i.Cursor.SetMode(cursor.Modes[o.CursorMode])
|
||||
i.CharLimit = o.CharLimit
|
||||
|
||||
theme := huh.ThemeCharm()
|
||||
theme.Focused.Base = lipgloss.NewStyle()
|
||||
theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss()
|
||||
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
|
||||
|
||||
// Keep input keymap backwards compatible
|
||||
keymap := huh.NewDefaultKeyMap()
|
||||
keymap.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc"))
|
||||
|
||||
echoMode := huh.EchoModeNormal
|
||||
if o.Password {
|
||||
i.EchoMode = textinput.EchoPassword
|
||||
i.EchoCharacter = '•'
|
||||
echoMode = huh.EchoModePassword
|
||||
}
|
||||
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
m := model{
|
||||
textinput: i,
|
||||
header: o.Header,
|
||||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
padding: []int{top, right, bottom, left},
|
||||
autoWidth: o.Width < 1,
|
||||
showHelp: o.ShowHelp,
|
||||
help: help.New(),
|
||||
keymap: defaultKeymap(),
|
||||
}
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
p := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithReportFocus(),
|
||||
tea.WithContext(ctx),
|
||||
)
|
||||
tm, err := p.Run()
|
||||
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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run input: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m = tm.(model)
|
||||
if !m.submitted {
|
||||
return errors.New("not submitted")
|
||||
}
|
||||
fmt.Println(m.textinput.Value())
|
||||
fmt.Println(value)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
100
input/input.go
100
input/input.go
|
|
@ -1,100 +0,0 @@
|
|||
// Package input provides a shell script interface for the text input bubble.
|
||||
// https://github.com/charmbracelet/bubbles/tree/master/textinput
|
||||
//
|
||||
// It can be used to prompt the user for some input. The text the user entered
|
||||
// will be sent to stdout.
|
||||
//
|
||||
// $ gum input --placeholder "What's your favorite gum?" > answer.text
|
||||
package input
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type keymap textinput.KeyMap
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
k := textinput.DefaultKeyMap
|
||||
return keymap(k)
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
autoWidth bool
|
||||
header string
|
||||
padding []int
|
||||
headerStyle lipgloss.Style
|
||||
textinput textinput.Model
|
||||
quitting bool
|
||||
submitted bool
|
||||
showHelp bool
|
||||
help help.Model
|
||||
keymap keymap
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if m.header != "" {
|
||||
parts = append(parts, m.headerStyle.Render(m.header))
|
||||
}
|
||||
|
||||
parts = append(parts, m.textinput.View())
|
||||
if m.showHelp {
|
||||
parts = append(parts, "", m.help.View(m.keymap))
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
parts...,
|
||||
))
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if m.autoWidth {
|
||||
m.textinput.Width = msg.Width - 1 -
|
||||
lipgloss.Width(m.textinput.Prompt) -
|
||||
m.padding[1] - m.padding[3]
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
case "esc":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.quitting = true
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
|
@ -18,10 +18,8 @@ type Options struct {
|
|||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
|
||||
Width int `help:"Input width (0 for terminal width)" default:"0" env:"GUM_INPUT_WIDTH"`
|
||||
Password bool `help:"Mask input characters" default:"false"`
|
||||
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_INPUT_SHOW_HELP"`
|
||||
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"true" env:"GUM_INPUT_SHOW_HELP"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"`
|
||||
Timeout time.Duration `help:"Timeout until input aborts" default:"0s" env:"GUM_INPUT_TIMEOUT"`
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_INPUT_STRIP_ANSI"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_INPUT_PADDING"`
|
||||
Timeout time.Duration `help:"Timeout until input aborts" default:"0" env:"GUM_INPUT_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package decode position strings to lipgloss.
|
||||
package decode
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
// Package exit code implementation.
|
||||
package exit
|
||||
|
||||
import "strconv"
|
||||
|
||||
// StatusTimeout is the exit code for timed out commands.
|
||||
const StatusTimeout = 124
|
||||
import "fmt"
|
||||
|
||||
// StatusAborted is the exit code for aborted commands.
|
||||
const StatusAborted = 130
|
||||
|
||||
// ErrExit is a custom exit error.
|
||||
type ErrExit int
|
||||
|
||||
// Error implements error.
|
||||
func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) }
|
||||
// ErrAborted is the error to return when a gum command is aborted by Ctrl + C.
|
||||
var ErrAborted = fmt.Errorf("aborted")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package files handles files.
|
||||
package files
|
||||
|
||||
import (
|
||||
|
|
@ -19,6 +18,7 @@ func List() []string {
|
|||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
|
|
|||
8
internal/log/log.go
Normal file
8
internal/log/log.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package log
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Error prints an error message to the user.
|
||||
func Error(message string) {
|
||||
fmt.Println("Error:", message)
|
||||
}
|
||||
26
internal/stack/stack.go
Normal file
26
internal/stack/stack.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package stack
|
||||
|
||||
// Stack is a stack interface for integers.
|
||||
type Stack struct {
|
||||
Push func(int)
|
||||
Pop func() int
|
||||
Length func() int
|
||||
}
|
||||
|
||||
// NewStack returns a new stack of integers.
|
||||
func NewStack() Stack {
|
||||
slice := make([]int, 0)
|
||||
return Stack{
|
||||
Push: func(i int) {
|
||||
slice = append(slice, i)
|
||||
},
|
||||
Pop: func() int {
|
||||
res := slice[len(slice)-1]
|
||||
slice = slice[:len(slice)-1]
|
||||
return res
|
||||
},
|
||||
Length: func() int {
|
||||
return len(slice)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// Package stdin handles processing input from stdin.
|
||||
package stdin
|
||||
|
||||
import (
|
||||
|
|
@ -7,58 +6,18 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
ansiStrip bool
|
||||
singleLine bool
|
||||
}
|
||||
|
||||
// Option is a read option.
|
||||
type Option func(*options)
|
||||
|
||||
// StripANSI optionally strips ansi sequences.
|
||||
func StripANSI(b bool) Option {
|
||||
return func(o *options) {
|
||||
o.ansiStrip = b
|
||||
}
|
||||
}
|
||||
|
||||
// SingleLine reads a single line.
|
||||
func SingleLine(b bool) Option {
|
||||
return func(o *options) {
|
||||
o.singleLine = b
|
||||
}
|
||||
}
|
||||
|
||||
// Read reads input from an stdin pipe.
|
||||
func Read(opts ...Option) (string, error) {
|
||||
func Read() (string, error) {
|
||||
if IsEmpty() {
|
||||
return "", fmt.Errorf("stdin is empty")
|
||||
}
|
||||
|
||||
options := options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
var b strings.Builder
|
||||
|
||||
if options.singleLine {
|
||||
line, _, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read line: %w", err)
|
||||
}
|
||||
_, err = b.Write(line)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for !options.singleLine {
|
||||
for {
|
||||
r, _, err := reader.ReadRune()
|
||||
if err != nil && err == io.EOF {
|
||||
break
|
||||
|
|
@ -69,11 +28,7 @@ func Read(opts ...Option) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
s := strings.TrimSpace(b.String())
|
||||
if options.ansiStrip {
|
||||
return ansi.Strip(s), nil
|
||||
}
|
||||
return s, nil
|
||||
return strings.TrimSuffix(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// IsEmpty returns whether stdin is empty.
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
// Package timeout handles context timeouts.
|
||||
package timeout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context setup a new context that times out if the given timeout is > 0.
|
||||
func Context(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
ctx := context.Background()
|
||||
if timeout == 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// Package tty provides tty-aware printing.
|
||||
package tty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/term"
|
||||
)
|
||||
|
||||
var isTTY = sync.OnceValue(func() bool {
|
||||
return term.IsTerminal(os.Stdout.Fd())
|
||||
})
|
||||
|
||||
// Println handles println, striping ansi sequences if stdout is not a tty.
|
||||
func Println(s string) {
|
||||
if isTTY() {
|
||||
fmt.Println(s)
|
||||
return
|
||||
}
|
||||
fmt.Println(ansi.Strip(s))
|
||||
}
|
||||
15
internal/utils/utils.go
Normal file
15
internal/utils/utils.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// LipglossPadding calculates how much padding a string is given by a style.
|
||||
func LipglossPadding(style lipgloss.Style) (int, int) {
|
||||
render := style.Render(" ")
|
||||
before := strings.Index(render, " ")
|
||||
after := len(render) - len(" ") - before
|
||||
return before, after
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// Package log the log command.
|
||||
package log
|
||||
|
||||
import (
|
||||
|
|
@ -17,7 +16,7 @@ func (o Options) Run() error {
|
|||
l := log.New(os.Stderr)
|
||||
|
||||
if o.File != "" {
|
||||
f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm) //nolint:gosec
|
||||
f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening file: %w", err)
|
||||
}
|
||||
|
|
@ -29,13 +28,6 @@ func (o Options) Run() error {
|
|||
l.SetPrefix(o.Prefix)
|
||||
l.SetLevel(-math.MaxInt32) // log all levels
|
||||
l.SetReportTimestamp(o.Time != "")
|
||||
if o.MinLevel != "" {
|
||||
lvl, err := log.ParseLevel(o.MinLevel)
|
||||
if err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
l.SetLevel(lvl)
|
||||
}
|
||||
|
||||
timeFormats := map[string]string{
|
||||
"layout": time.Layout,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@ type Options struct {
|
|||
Structured bool `short:"s" help:"Use structured logging" xor:"format,structured"`
|
||||
Time string `short:"t" help:"The time format to use (kitchen, layout, ansic, rfc822, etc...)" default:""`
|
||||
|
||||
MinLevel string `help:"Minimal level to show" default:"" env:"GUM_LOG_LEVEL"`
|
||||
|
||||
LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"`
|
||||
LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"` //nolint:staticcheck
|
||||
TimeStyle style.Styles `embed:"" prefix:"time." help:"The style of the time" envprefix:"GUM_LOG_TIME_"`
|
||||
PrefixStyle style.Styles `embed:"" prefix:"prefix." help:"The style of the prefix" set:"defaultBold=true" set:"defaultFaint=true" envprefix:"GUM_LOG_PREFIX_"` //nolint:staticcheck
|
||||
MessageStyle style.Styles `embed:"" prefix:"message." help:"The style of the message" envprefix:"GUM_LOG_MESSAGE_"`
|
||||
|
|
|
|||
19
main.go
19
main.go
|
|
@ -1,4 +1,3 @@
|
|||
// Package main is Gum: a tool for glamorous shell scripts.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -8,10 +7,11 @@ import (
|
|||
"runtime/debug"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
)
|
||||
|
||||
const shaLen = 7
|
||||
|
|
@ -55,7 +55,6 @@ func main() {
|
|||
}),
|
||||
kong.Vars{
|
||||
"version": version,
|
||||
"versionNumber": Version,
|
||||
"defaultHeight": "0",
|
||||
"defaultWidth": "0",
|
||||
"defaultAlign": "left",
|
||||
|
|
@ -74,18 +73,10 @@ func main() {
|
|||
},
|
||||
)
|
||||
if err := ctx.Run(); err != nil {
|
||||
var ex exit.ErrExit
|
||||
if errors.As(err, &ex) {
|
||||
os.Exit(int(ex))
|
||||
}
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
if errors.Is(err, exit.ErrAborted) || errors.Is(err, huh.ErrUserAborted) {
|
||||
os.Exit(exit.StatusAborted)
|
||||
}
|
||||
if errors.Is(err, tea.ErrProgramKilled) {
|
||||
fmt.Fprintln(os.Stderr, "timed out")
|
||||
os.Exit(exit.StatusTimeout)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package man the man command.
|
||||
package man
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ import (
|
|||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for the viewport bubble.
|
||||
|
|
@ -31,9 +29,9 @@ func (o Options) Run() error {
|
|||
}
|
||||
}
|
||||
|
||||
m := model{
|
||||
model := model{
|
||||
viewport: vp,
|
||||
help: help.New(),
|
||||
helpStyle: o.HelpStyle.ToLipgloss(),
|
||||
content: o.Content,
|
||||
origContent: o.Content,
|
||||
showLineNumbers: o.ShowLineNumbers,
|
||||
|
|
@ -41,21 +39,12 @@ func (o Options) Run() error {
|
|||
softWrap: o.SoftWrap,
|
||||
matchStyle: o.MatchStyle.ToLipgloss(),
|
||||
matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(),
|
||||
keymap: defaultKeymap(),
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
}
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := tea.NewProgram(
|
||||
m,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithReportFocus(),
|
||||
tea.WithContext(ctx),
|
||||
).Run()
|
||||
_, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start program: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ import (
|
|||
type Options struct {
|
||||
//nolint:staticcheck
|
||||
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
|
||||
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
|
||||
Content string `arg:"" optional:"" help:"Display content to scroll"`
|
||||
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
|
||||
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
|
||||
SoftWrap bool `help:"Soft wrap lines" default:"true" negatable:""`
|
||||
SoftWrap bool `help:"Soft wrap lines" default:"false"`
|
||||
MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck
|
||||
MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck
|
||||
Timeout time.Duration `help:"Timeout until command exits" default:"0s" env:"GUM_PAGER_TIMEOUT"`
|
||||
|
||||
// Deprecated: this has no effect anymore.
|
||||
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_" hidden:""`
|
||||
Timeout time.Duration `help:"Timeout until command exits" default:"0" env:"GUM_PAGER_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
186
pager/pager.go
186
pager/pager.go
|
|
@ -6,93 +6,21 @@ package pager
|
|||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/gum/timeout"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
type keymap struct {
|
||||
Home,
|
||||
End,
|
||||
Search,
|
||||
NextMatch,
|
||||
PrevMatch,
|
||||
Abort,
|
||||
Quit,
|
||||
ConfirmSearch,
|
||||
CancelSearch key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("up", "down"),
|
||||
key.WithHelp("↓↑", "navigate"),
|
||||
),
|
||||
k.Quit,
|
||||
k.Search,
|
||||
k.NextMatch,
|
||||
k.PrevMatch,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
return keymap{
|
||||
Home: key.NewBinding(
|
||||
key.WithKeys("g", "home"),
|
||||
key.WithHelp("h", "home"),
|
||||
),
|
||||
End: key.NewBinding(
|
||||
key.WithKeys("G", "end"),
|
||||
key.WithHelp("G", "end"),
|
||||
),
|
||||
Search: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "search"),
|
||||
),
|
||||
PrevMatch: key.NewBinding(
|
||||
key.WithKeys("p", "N"),
|
||||
key.WithHelp("N", "previous match"),
|
||||
),
|
||||
NextMatch: key.NewBinding(
|
||||
key.WithKeys("n"),
|
||||
key.WithHelp("n", "next match"),
|
||||
),
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "abort"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "esc"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
ConfirmSearch: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "confirm"),
|
||||
),
|
||||
CancelSearch: key.NewBinding(
|
||||
key.WithKeys("ctrl+c", "ctrl+d", "esc"),
|
||||
key.WithHelp("ctrl+c", "cancel"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
content string
|
||||
origContent string
|
||||
viewport viewport.Model
|
||||
help help.Model
|
||||
helpStyle lipgloss.Style
|
||||
showLineNumbers bool
|
||||
lineNumberStyle lipgloss.Style
|
||||
softWrap bool
|
||||
|
|
@ -100,33 +28,34 @@ type model struct {
|
|||
matchStyle lipgloss.Style
|
||||
matchHighlightStyle lipgloss.Style
|
||||
maxWidth int
|
||||
keymap keymap
|
||||
timeout time.Duration
|
||||
hasTimeout bool
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
func (m model) Init() tea.Cmd {
|
||||
return timeout.Init(m.timeout, nil)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case timeout.TickTimeoutMsg:
|
||||
if msg.TimeoutValue <= 0 {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.timeout = msg.TimeoutValue
|
||||
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.processText(msg)
|
||||
m.ProcessText(msg)
|
||||
case tea.KeyMsg:
|
||||
return m.keyHandler(msg)
|
||||
return m.KeyHandler(msg)
|
||||
}
|
||||
|
||||
m.keymap.PrevMatch.SetEnabled(m.search.query != nil)
|
||||
m.keymap.NextMatch.SetEnabled(m.search.query != nil)
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.search.input, cmd = m.search.input.Update(msg)
|
||||
return m, cmd
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) helpView() string {
|
||||
return m.help.View(m.keymap)
|
||||
}
|
||||
|
||||
func (m *model) processText(msg tea.WindowSizeMsg) {
|
||||
m.viewport.Height = msg.Height - lipgloss.Height(m.helpView())
|
||||
func (m *model) ProcessText(msg tea.WindowSizeMsg) {
|
||||
m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1
|
||||
m.viewport.Width = msg.Width
|
||||
textStyle := lipgloss.NewStyle().Width(m.viewport.Width)
|
||||
var text strings.Builder
|
||||
|
|
@ -146,21 +75,17 @@ func (m *model) processText(msg tea.WindowSizeMsg) {
|
|||
if m.showLineNumbers {
|
||||
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
|
||||
}
|
||||
idx := 0
|
||||
if w := ansi.StringWidth(line); m.softWrap && w > m.maxWidth {
|
||||
for w > idx {
|
||||
if m.showLineNumbers && idx != 0 {
|
||||
text.WriteString(m.lineNumberStyle.Render(" │ "))
|
||||
}
|
||||
truncatedLine := ansi.Cut(line, idx, m.maxWidth+idx)
|
||||
idx += m.maxWidth
|
||||
text.WriteString(textStyle.Render(truncatedLine))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
text.WriteString(textStyle.Render(line))
|
||||
for m.softWrap && lipgloss.Width(line) > m.maxWidth {
|
||||
truncatedLine := truncate.String(line, uint(m.maxWidth))
|
||||
text.WriteString(textStyle.Render(truncatedLine))
|
||||
text.WriteString("\n")
|
||||
if m.showLineNumbers {
|
||||
text.WriteString(m.lineNumberStyle.Render(" │ "))
|
||||
}
|
||||
line = strings.Replace(line, truncatedLine, "", 1)
|
||||
}
|
||||
text.WriteString(textStyle.Render(truncate.String(line, uint(m.maxWidth))))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
|
||||
diffHeight := m.viewport.Height - lipgloss.Height(text.String())
|
||||
|
|
@ -173,57 +98,62 @@ func (m *model) processText(msg tea.WindowSizeMsg) {
|
|||
|
||||
const heightOffset = 2
|
||||
|
||||
func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
km := m.keymap
|
||||
func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) {
|
||||
var cmd tea.Cmd
|
||||
if m.search.active {
|
||||
switch {
|
||||
case key.Matches(msg, km.ConfirmSearch):
|
||||
switch key.String() {
|
||||
case "enter":
|
||||
if m.search.input.Value() != "" {
|
||||
m.content = m.origContent
|
||||
m.search.Execute(&m)
|
||||
|
||||
// Trigger a view update to highlight the found matches.
|
||||
m.search.NextMatch(&m)
|
||||
m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
} else {
|
||||
m.search.Done()
|
||||
}
|
||||
case key.Matches(msg, km.CancelSearch):
|
||||
case "ctrl+d", "ctrl+c", "esc":
|
||||
m.search.Done()
|
||||
default:
|
||||
m.search.input, cmd = m.search.input.Update(msg)
|
||||
m.search.input, cmd = m.search.input.Update(key)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case key.Matches(msg, km.Home):
|
||||
switch key.String() {
|
||||
case "g", "home":
|
||||
m.viewport.GotoTop()
|
||||
case key.Matches(msg, km.End):
|
||||
case "G", "end":
|
||||
m.viewport.GotoBottom()
|
||||
case key.Matches(msg, km.Search):
|
||||
case "/":
|
||||
m.search.Begin()
|
||||
return m, textinput.Blink
|
||||
case key.Matches(msg, km.PrevMatch):
|
||||
case "p", "N":
|
||||
m.search.PrevMatch(&m)
|
||||
m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
case key.Matches(msg, km.NextMatch):
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
case "n":
|
||||
m.search.NextMatch(&m)
|
||||
m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
case key.Matches(msg, km.Quit):
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width})
|
||||
case "q", "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Abort):
|
||||
return m, tea.Interrupt
|
||||
}
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
m.viewport, cmd = m.viewport.Update(key)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var timeoutStr string
|
||||
if m.hasTimeout {
|
||||
timeoutStr = timeout.Str(m.timeout) + " "
|
||||
}
|
||||
helpMsg := "\n" + timeoutStr + " ↑/↓: Navigate • q: Quit • /: Search "
|
||||
if m.search.query != nil {
|
||||
helpMsg += "• n: Next Match "
|
||||
helpMsg += "• N: Prev Match "
|
||||
}
|
||||
if m.search.active {
|
||||
return m.viewport.View() + "\n " + m.search.input.View()
|
||||
return m.viewport.View() + "\n" + timeoutStr + " " + m.search.input.View()
|
||||
}
|
||||
|
||||
return m.viewport.View() + "\n" + m.helpView()
|
||||
return m.viewport.View() + m.helpStyle.Render(helpMsg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/gum/internal/utils"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
type search struct {
|
||||
|
|
@ -51,7 +52,7 @@ func (s *search) Execute(m *model) {
|
|||
m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1"))
|
||||
|
||||
// Recompile the regex to match the an replace the highlights.
|
||||
leftPad, _ := lipglossPadding(m.matchStyle)
|
||||
leftPad, _ := utils.LipglossPadding(m.matchStyle)
|
||||
matchingString := regexp.QuoteMeta(m.matchStyle.Render()[:leftPad]) + s.query.String() + regexp.QuoteMeta(m.matchStyle.Render()[leftPad:])
|
||||
s.query, err = regexp.Compile(matchingString)
|
||||
if err != nil {
|
||||
|
|
@ -81,7 +82,7 @@ func (s *search) NextMatch(m *model) {
|
|||
return
|
||||
}
|
||||
|
||||
leftPad, rightPad := lipglossPadding(m.matchStyle)
|
||||
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
|
||||
s.matchIndex = (s.matchIndex + 1) % len(allMatches)
|
||||
match := allMatches[s.matchIndex]
|
||||
lhs := m.content[:match[0]]
|
||||
|
|
@ -124,7 +125,7 @@ func (s *search) PrevMatch(m *model) {
|
|||
s.matchIndex = len(allMatches) - 1
|
||||
}
|
||||
|
||||
leftPad, rightPad := lipglossPadding(m.matchStyle)
|
||||
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
|
||||
match := allMatches[s.matchIndex]
|
||||
lhs := m.content[:match[0]]
|
||||
rhs := m.content[match[0]:]
|
||||
|
|
@ -149,27 +150,15 @@ func (s *search) PrevMatch(m *model) {
|
|||
func softWrapEm(str string, maxWidth int, softWrap bool) string {
|
||||
var text strings.Builder
|
||||
for _, line := range strings.Split(str, "\n") {
|
||||
idx := 0
|
||||
if w := ansi.StringWidth(line); softWrap && w > maxWidth {
|
||||
for w > idx {
|
||||
truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
|
||||
idx += maxWidth
|
||||
text.WriteString(truncatedLine)
|
||||
text.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
text.WriteString(line)
|
||||
for softWrap && lipgloss.Width(line) > maxWidth {
|
||||
truncatedLine := truncate.String(line, uint(maxWidth))
|
||||
text.WriteString(truncatedLine)
|
||||
text.WriteString("\n")
|
||||
line = strings.Replace(line, truncatedLine, "", 1)
|
||||
}
|
||||
text.WriteString(truncate.String(line, uint(maxWidth)))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// lipglossPadding calculates how much padding a string is given by a style.
|
||||
func lipglossPadding(style lipgloss.Style) (int, int) {
|
||||
render := style.Render(" ")
|
||||
before := strings.Index(render, " ")
|
||||
after := len(render) - len(" ") - before
|
||||
return before, after
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,77 +6,64 @@ import (
|
|||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/x/term"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for the spinner bubble.
|
||||
// https://github.com/charmbracelet/bubbles/spinner
|
||||
func (o Options) Run() error {
|
||||
isOutTTY := term.IsTerminal(os.Stdout.Fd())
|
||||
isErrTTY := term.IsTerminal(os.Stderr.Fd())
|
||||
isTTY := term.IsTerminal(os.Stdout.Fd())
|
||||
|
||||
s := spinner.New()
|
||||
s.Style = o.SpinnerStyle.ToLipgloss()
|
||||
s.Spinner = spinnerMap[o.Spinner]
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
m := model{
|
||||
spinner: s,
|
||||
title: o.TitleStyle.ToLipgloss().Render(o.Title),
|
||||
command: o.Command,
|
||||
align: o.Align,
|
||||
showStdout: (o.ShowOutput || o.ShowStdout) && isOutTTY,
|
||||
showStderr: (o.ShowOutput || o.ShowStderr) && isErrTTY,
|
||||
showOutput: o.ShowOutput && isTTY,
|
||||
showError: o.ShowError,
|
||||
isTTY: isErrTTY,
|
||||
padding: []int{top, right, bottom, left},
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithOutput(os.Stderr))
|
||||
mm, err := p.Run()
|
||||
m = mm.(model)
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
tm, err := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(nil),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to run action: %w", err)
|
||||
return fmt.Errorf("failed to run spin: %w", err)
|
||||
}
|
||||
|
||||
if m.aborted {
|
||||
return exit.ErrAborted
|
||||
}
|
||||
|
||||
m = tm.(model)
|
||||
// If the command succeeds, and we are printing output and we are in a TTY then push the STDOUT we got to the actual
|
||||
// STDOUT for piping or other things.
|
||||
//nolint:nestif
|
||||
if m.err != nil {
|
||||
if _, err := fmt.Fprintf(os.Stderr, "%s\n", m.err.Error()); err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
}
|
||||
return exit.ErrExit(1)
|
||||
} else if m.status == 0 {
|
||||
var output string
|
||||
if o.ShowOutput || (o.ShowStdout && o.ShowStderr) {
|
||||
output = m.output
|
||||
} else if o.ShowStdout {
|
||||
output = m.stdout
|
||||
} else if o.ShowStderr {
|
||||
output = m.stderr
|
||||
}
|
||||
if output != "" {
|
||||
if _, err := os.Stdout.WriteString(output); err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
if m.status == 0 {
|
||||
if o.ShowOutput {
|
||||
// BubbleTea writes the View() to stderr.
|
||||
// If the program is being piped then put the accumulated output in stdout.
|
||||
if !isTTY {
|
||||
_, err := os.Stdout.WriteString(m.stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if o.ShowError {
|
||||
// Otherwise if we are showing errors and the command did not exit with a 0 status code then push all of the command
|
||||
// output to the terminal. This way failed commands can be debugged.
|
||||
if _, err := os.Stdout.WriteString(m.output); err != nil {
|
||||
_, err := os.Stdout.WriteString(m.output)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to stdout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return exit.ErrExit(m.status)
|
||||
os.Exit(m.status)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,12 @@ import (
|
|||
type Options struct {
|
||||
Command []string `arg:"" help:"Command to run"`
|
||||
|
||||
ShowOutput bool `help:"Show or pipe output of command during execution (shows both STDOUT and STDERR)" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
|
||||
ShowOutput bool `help:"Show or pipe output of command during execution" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"`
|
||||
ShowError bool `help:"Show output of command only if the command fails" default:"false" env:"GUM_SPIN_SHOW_ERROR"`
|
||||
ShowStdout bool `help:"Show STDOUT output" default:"false" env:"GUM_SPIN_SHOW_STDOUT"`
|
||||
ShowStderr bool `help:"Show STDERR errput" default:"false" env:"GUM_SPIN_SHOW_STDERR"`
|
||||
Spinner string `help:"Spinner type" short:"s" type:"spinner" enum:"line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger" default:"dot" env:"GUM_SPIN_SPINNER"`
|
||||
SpinnerStyle style.Styles `embed:"" prefix:"spinner." set:"defaultForeground=212" envprefix:"GUM_SPIN_SPINNER_"`
|
||||
Title string `help:"Text to display to user while spinning" default:"Loading..." env:"GUM_SPIN_TITLE"`
|
||||
TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_SPIN_TITLE_"`
|
||||
Align string `help:"Alignment of spinner with regard to the title" short:"a" type:"align" enum:"left,right" default:"left" env:"GUM_SPIN_ALIGN"`
|
||||
Timeout time.Duration `help:"Timeout until spin command aborts" default:"0s" env:"GUM_SPIN_TIMEOUT"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_SPIN_PADDING"`
|
||||
Timeout time.Duration `help:"Timeout until spin command aborts" default:"0" env:"GUM_SPIN_TIMEOUT"`
|
||||
}
|
||||
|
|
|
|||
22
spin/pty.go
22
spin/pty.go
|
|
@ -1,22 +0,0 @@
|
|||
package spin
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/charmbracelet/x/xpty"
|
||||
)
|
||||
|
||||
func openPty(f *os.File) (pty xpty.Pty, err error) {
|
||||
width, height, err := term.GetSize(f.Fd())
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
pty, err = xpty.NewPty(width, height)
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
return pty, nil
|
||||
}
|
||||
142
spin/spin.go
142
spin/spin.go
|
|
@ -15,49 +15,43 @@
|
|||
package spin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/timeout"
|
||||
"github.com/charmbracelet/x/term"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"github.com/charmbracelet/x/xpty"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
spinner spinner.Model
|
||||
title string
|
||||
padding []int
|
||||
align string
|
||||
command []string
|
||||
quitting bool
|
||||
isTTY bool
|
||||
aborted bool
|
||||
status int
|
||||
stdout string
|
||||
stderr string
|
||||
output string
|
||||
showStdout bool
|
||||
showStderr bool
|
||||
showOutput bool
|
||||
showError bool
|
||||
err error
|
||||
timeout time.Duration
|
||||
hasTimeout bool
|
||||
}
|
||||
|
||||
var (
|
||||
bothbuf bytes.Buffer
|
||||
outbuf bytes.Buffer
|
||||
errbuf bytes.Buffer
|
||||
|
||||
executing *exec.Cmd
|
||||
bothbuf strings.Builder
|
||||
outbuf strings.Builder
|
||||
errbuf strings.Builder
|
||||
)
|
||||
|
||||
type errorMsg error
|
||||
|
||||
type finishCommandMsg struct {
|
||||
stdout string
|
||||
stderr string
|
||||
|
|
@ -71,54 +65,22 @@ func commandStart(command []string) tea.Cmd {
|
|||
if len(command) > 1 {
|
||||
args = command[1:]
|
||||
}
|
||||
cmd := exec.Command(command[0], args...) //nolint:gosec
|
||||
|
||||
executing = exec.CommandContext(context.Background(), command[0], args...) //nolint:gosec
|
||||
executing.Stdin = os.Stdin
|
||||
if term.IsTerminal(os.Stdout.Fd()) {
|
||||
stdout := io.MultiWriter(&bothbuf, &errbuf)
|
||||
stderr := io.MultiWriter(&bothbuf, &outbuf)
|
||||
|
||||
isTerminal := term.IsTerminal(os.Stdout.Fd())
|
||||
|
||||
// NOTE(@andreynering): We had issues with Git Bash on Windows
|
||||
// when it comes to handling PTYs, so we're falling back to
|
||||
// to redirecting stdout/stderr as usual to avoid issues.
|
||||
//nolint:nestif
|
||||
if isTerminal && runtime.GOOS == "windows" {
|
||||
executing.Stdout = io.MultiWriter(&bothbuf, &outbuf)
|
||||
executing.Stderr = io.MultiWriter(&bothbuf, &errbuf)
|
||||
_ = executing.Run()
|
||||
} else if isTerminal {
|
||||
stdoutPty, err := openPty(os.Stdout)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
defer stdoutPty.Close() //nolint:errcheck
|
||||
|
||||
stderrPty, err := openPty(os.Stderr)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
defer stderrPty.Close() //nolint:errcheck
|
||||
|
||||
if outUnixPty, isOutUnixPty := stdoutPty.(*xpty.UnixPty); isOutUnixPty {
|
||||
executing.Stdout = outUnixPty.Slave()
|
||||
}
|
||||
if errUnixPty, isErrUnixPty := stderrPty.(*xpty.UnixPty); isErrUnixPty {
|
||||
executing.Stderr = errUnixPty.Slave()
|
||||
}
|
||||
|
||||
go io.Copy(io.MultiWriter(&bothbuf, &outbuf), stdoutPty) //nolint:errcheck
|
||||
go io.Copy(io.MultiWriter(&bothbuf, &errbuf), stderrPty) //nolint:errcheck
|
||||
|
||||
if err = stdoutPty.Start(executing); err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
_ = xpty.WaitProcess(context.Background(), executing)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
} else {
|
||||
executing.Stdout = os.Stdout
|
||||
executing.Stderr = os.Stderr
|
||||
_ = executing.Run()
|
||||
cmd.Stdout = os.Stdout
|
||||
}
|
||||
|
||||
status := executing.ProcessState.ExitCode()
|
||||
_ = cmd.Run()
|
||||
|
||||
status := cmd.ProcessState.ExitCode()
|
||||
|
||||
if status == -1 {
|
||||
status = 1
|
||||
}
|
||||
|
|
@ -132,50 +94,48 @@ func commandStart(command []string) tea.Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
func commandAbort() tea.Msg {
|
||||
if executing != nil && executing.Process != nil {
|
||||
_ = executing.Process.Signal(syscall.SIGINT)
|
||||
}
|
||||
return tea.InterruptMsg{}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
commandStart(m.command),
|
||||
timeout.Init(m.timeout, nil),
|
||||
)
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
if m.quitting && m.showOutput {
|
||||
return strings.TrimPrefix(errbuf.String()+"\n"+outbuf.String(), "\n")
|
||||
}
|
||||
|
||||
var out string
|
||||
if m.showStderr {
|
||||
out += errbuf.String()
|
||||
var str string
|
||||
if m.hasTimeout {
|
||||
str = timeout.Str(m.timeout)
|
||||
}
|
||||
if m.showStdout {
|
||||
out += outbuf.String()
|
||||
}
|
||||
|
||||
if !m.isTTY {
|
||||
return m.title
|
||||
}
|
||||
|
||||
var header string
|
||||
if m.align == "left" {
|
||||
header = m.spinner.View() + " " + m.title
|
||||
header = m.spinner.View() + str + " " + m.title
|
||||
} else {
|
||||
header = m.title + " " + m.spinner.View()
|
||||
header = str + " " + m.title + " " + m.spinner.View()
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(header, "", out)
|
||||
if !m.showOutput {
|
||||
return header
|
||||
}
|
||||
return header + errbuf.String() + "\n" + outbuf.String()
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case timeout.TickTimeoutMsg:
|
||||
if msg.TimeoutValue <= 0 {
|
||||
// grab current output before closing for piped instances
|
||||
m.stdout = outbuf.String()
|
||||
|
||||
m.status = exit.StatusAborted
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.timeout = msg.TimeoutValue
|
||||
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
|
||||
case finishCommandMsg:
|
||||
m.stdout = msg.stdout
|
||||
m.stderr = msg.stderr
|
||||
|
|
@ -186,15 +146,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, commandAbort
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
case errorMsg:
|
||||
m.err = msg
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
#
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
#######
|
||||
# #
|
||||
# #
|
||||
|
|
@ -20,18 +20,11 @@ func (o Options) Run() error {
|
|||
if len(o.Text) > 0 {
|
||||
text = strings.Join(o.Text, "\n")
|
||||
} else {
|
||||
text, _ = stdin.Read(stdin.StripANSI(o.StripANSI))
|
||||
text, _ = stdin.Read()
|
||||
if text == "" {
|
||||
return errors.New("no input provided, see `gum style --help`")
|
||||
}
|
||||
}
|
||||
if o.Trim {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
lines = append(lines, strings.TrimSpace(line))
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
}
|
||||
fmt.Println(o.Style.ToLipgloss().Render(text))
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func (s Styles) ToLipgloss() lipgloss.Style {
|
|||
Height(s.Height).
|
||||
Width(s.Width).
|
||||
Margin(parseMargin(s.Margin)).
|
||||
Padding(ParsePadding(s.Padding)).
|
||||
Padding(parsePadding(s.Padding)).
|
||||
Bold(s.Bold).
|
||||
Faint(s.Faint).
|
||||
Italic(s.Italic).
|
||||
|
|
@ -40,7 +40,7 @@ func (s StylesNotHidden) ToLipgloss() lipgloss.Style {
|
|||
Height(s.Height).
|
||||
Width(s.Width).
|
||||
Margin(parseMargin(s.Margin)).
|
||||
Padding(ParsePadding(s.Padding)).
|
||||
Padding(parsePadding(s.Padding)).
|
||||
Bold(s.Bold).
|
||||
Faint(s.Faint).
|
||||
Italic(s.Italic).
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ package style
|
|||
|
||||
// Options is the customization options for the style command.
|
||||
type Options struct {
|
||||
Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
|
||||
Trim bool `help:"Trim whitespaces on every input line" default:"false"`
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_STYLE_STRIP_ANSI"`
|
||||
Style StylesNotHidden `embed:""`
|
||||
Text []string `arg:"" optional:"" help:"Text to which to apply the style"`
|
||||
Style StylesNotHidden `embed:""`
|
||||
}
|
||||
|
||||
// Styles is a flag set of possible styles.
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
minTokens = 1
|
||||
halfTokens = 2
|
||||
maxTokens = 4
|
||||
)
|
||||
const minTokens = 1
|
||||
const halfTokens = 2
|
||||
const maxTokens = 4
|
||||
|
||||
// ParsePadding parses 1 - 4 integers from a string and returns them in a top,
|
||||
// parsePadding parses 1 - 4 integers from a string and returns them in a top,
|
||||
// right, bottom, left order for use in the lipgloss.Padding() method.
|
||||
func ParsePadding(s string) (int, int, int, int) {
|
||||
func parsePadding(s string) (int, int, int, int) {
|
||||
var ints [maxTokens]int
|
||||
|
||||
tokens := strings.Split(s, " ")
|
||||
|
|
@ -48,4 +46,4 @@ func ParsePadding(s string) (int, int, int, int) {
|
|||
|
||||
// parseMargin is an alias for parsePadding since they involve the same logic
|
||||
// to parse integers to the same format.
|
||||
var parseMargin = ParsePadding
|
||||
var parseMargin = parsePadding
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
"first_name","last_name","username"
|
||||
"Rob","Pike",rob
|
||||
Ken,Thompson,ken
|
||||
"Robert","Griesemer","gri"
|
||||
|
|
|
@ -5,40 +5,31 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
ltable "github.com/charmbracelet/lipgloss/table"
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for rendering tabular data (CSV).
|
||||
func (o Options) Run() error {
|
||||
var input *os.File
|
||||
var reader *csv.Reader
|
||||
if o.File != "" {
|
||||
var err error
|
||||
input, err = os.Open(o.File)
|
||||
file, err := os.Open(o.File)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not render file: %w", err)
|
||||
return fmt.Errorf("could not find file at path %s", o.File)
|
||||
}
|
||||
reader = csv.NewReader(file)
|
||||
} else {
|
||||
if stdin.IsEmpty() {
|
||||
return fmt.Errorf("no data provided")
|
||||
}
|
||||
input = os.Stdin
|
||||
reader = csv.NewReader(os.Stdin)
|
||||
}
|
||||
defer input.Close() //nolint: errcheck
|
||||
|
||||
transformer := unicode.BOMOverride(encoding.Nop.NewDecoder())
|
||||
reader := csv.NewReader(transform.NewReader(input, transformer))
|
||||
reader.LazyQuotes = o.LazyQuotes
|
||||
reader.FieldsPerRecord = o.FieldsPerRecord
|
||||
separatorRunes := []rune(o.Separator)
|
||||
if len(separatorRunes) != 1 {
|
||||
return fmt.Errorf("separator must be single character")
|
||||
|
|
@ -79,7 +70,6 @@ func (o Options) Run() error {
|
|||
}
|
||||
|
||||
defaultStyles := table.DefaultStyles()
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
|
||||
styles := table.Styles{
|
||||
Cell: defaultStyles.Cell.Inherit(o.CellStyle.ToLipgloss()),
|
||||
|
|
@ -88,26 +78,11 @@ func (o Options) Run() error {
|
|||
}
|
||||
|
||||
rows := make([]table.Row, 0, len(data))
|
||||
for row := range data {
|
||||
if len(data[row]) > len(columns) {
|
||||
for _, row := range data {
|
||||
if len(row) > len(columns) {
|
||||
return fmt.Errorf("invalid number of columns")
|
||||
}
|
||||
|
||||
// fixes the data in case we have more columns than rows:
|
||||
for len(data[row]) < len(columns) {
|
||||
data[row] = append(data[row], "")
|
||||
}
|
||||
|
||||
for i, col := range data[row] {
|
||||
if len(o.Widths) == 0 {
|
||||
width := lipgloss.Width(col)
|
||||
if width > columns[i].Width {
|
||||
columns[i].Width = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row(data[row]))
|
||||
rows = append(rows, table.Row(row))
|
||||
}
|
||||
|
||||
if o.Print {
|
||||
|
|
@ -127,34 +102,15 @@ func (o Options) Run() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
opts := []table.Option{
|
||||
table := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(o.Height),
|
||||
table.WithRows(rows),
|
||||
table.WithStyles(styles),
|
||||
}
|
||||
if o.Height > 0 {
|
||||
opts = append(opts, table.WithHeight(o.Height-top-bottom))
|
||||
}
|
||||
)
|
||||
|
||||
table := table.New(opts...)
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
m := model{
|
||||
table: table,
|
||||
showHelp: o.ShowHelp,
|
||||
hideCount: o.HideCount,
|
||||
help: help.New(),
|
||||
keymap: defaultKeymap(),
|
||||
padding: []int{top, right, bottom, left},
|
||||
}
|
||||
tm, err := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithContext(ctx),
|
||||
).Run()
|
||||
tm, err := tea.NewProgram(model{table: table}, tea.WithOutput(os.Stderr)).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start tea program: %w", err)
|
||||
}
|
||||
|
|
@ -163,15 +119,10 @@ func (o Options) Run() error {
|
|||
return fmt.Errorf("failed to get selection")
|
||||
}
|
||||
|
||||
m = tm.(model)
|
||||
if o.ReturnColumn > 0 && o.ReturnColumn <= len(m.selected) {
|
||||
if err = writer.Write([]string{m.selected[o.ReturnColumn-1]}); err != nil {
|
||||
return fmt.Errorf("failed to write col %d of selected row: %w", o.ReturnColumn, err)
|
||||
}
|
||||
} else {
|
||||
if err = writer.Write([]string(m.selected)); err != nil {
|
||||
return fmt.Errorf("failed to write selected row: %w", err)
|
||||
}
|
||||
m := tm.(model)
|
||||
|
||||
if err = writer.Write([]string(m.selected)); err != nil {
|
||||
return fmt.Errorf("failed to write selected row: %w", err)
|
||||
}
|
||||
|
||||
writer.Flush()
|
||||
|
|
|
|||
|
|
@ -1,30 +1,19 @@
|
|||
package table
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/gum/style"
|
||||
)
|
||||
import "github.com/charmbracelet/gum/style"
|
||||
|
||||
// Options is the customization options for the table command.
|
||||
type Options struct {
|
||||
Separator string `short:"s" help:"Row separator" default:","`
|
||||
Columns []string `short:"c" help:"Column names"`
|
||||
Widths []int `short:"w" help:"Column widths"`
|
||||
Height int `help:"Table height" default:"0"`
|
||||
Print bool `short:"p" help:"static print" default:"false"`
|
||||
File string `short:"f" help:"file path" default:""`
|
||||
Border string `short:"b" help:"border style" default:"rounded" enum:"rounded,thick,normal,hidden,double,none"`
|
||||
ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_TABLE_SHOW_HELP"`
|
||||
HideCount bool `help:"Hide item count on help keybinds" default:"false" negatable:"" env:"GUM_TABLE_HIDE_COUNT"`
|
||||
LazyQuotes bool `help:"If LazyQuotes is true, a quote may appear in an unquoted field and a non-doubled quote may appear in a quoted field" default:"false" env:"GUM_TABLE_LAZY_QUOTES"`
|
||||
FieldsPerRecord int `help:"Sets the number of expected fields per record" default:"0" env:"GUM_TABLE_FIELDS_PER_RECORD"`
|
||||
Separator string `short:"s" help:"Row separator" default:","`
|
||||
Columns []string `short:"c" help:"Column names"`
|
||||
Widths []int `short:"w" help:"Column widths"`
|
||||
Height int `help:"Table height" default:"0"`
|
||||
Print bool `short:"p" help:"static print" default:"false"`
|
||||
File string `short:"f" help:"file path" default:""`
|
||||
Border string `short:"b" help:"border style" default:"rounded" enum:"rounded,thick,normal,hidden,double,none"`
|
||||
|
||||
BorderStyle style.Styles `embed:"" prefix:"border." envprefix:"GUM_TABLE_BORDER_"`
|
||||
CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER_"`
|
||||
SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED_"`
|
||||
ReturnColumn int `short:"r" help:"Which column number should be returned instead of whole row as string. Default=0 returns whole Row" default:"0"`
|
||||
Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_TABLE_TIMEOUT"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_TABLE_PADDING"`
|
||||
BorderStyle style.Styles `embed:"" prefix:"border." envprefix:"GUM_TABLE_BORDER_"`
|
||||
CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER_"`
|
||||
SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED_"`
|
||||
}
|
||||
|
|
|
|||
103
table/table.go
103
table/table.go
|
|
@ -15,81 +15,18 @@
|
|||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type keymap struct {
|
||||
Navigate,
|
||||
Select,
|
||||
Quit,
|
||||
Abort key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.Navigate,
|
||||
k.Select,
|
||||
k.Quit,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
return keymap{
|
||||
Navigate: key.NewBinding(
|
||||
key.WithKeys("up", "down"),
|
||||
key.WithHelp("↓↑", "navigate"),
|
||||
),
|
||||
Select: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+q", "q"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "abort"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
table table.Model
|
||||
selected table.Row
|
||||
quitting bool
|
||||
showHelp bool
|
||||
hideCount bool
|
||||
help help.Model
|
||||
keymap keymap
|
||||
padding []int
|
||||
table table.Model
|
||||
selected table.Row
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) countView() string {
|
||||
if m.hideCount {
|
||||
return ""
|
||||
}
|
||||
|
||||
padding := strconv.Itoa(numLen(len(m.table.Rows())))
|
||||
return m.help.Styles.FullDesc.Render(fmt.Sprintf(
|
||||
"%"+padding+"d/%d%s",
|
||||
m.table.Cursor()+1,
|
||||
len(m.table.Rows()),
|
||||
m.help.ShortSeparator,
|
||||
))
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
|
@ -97,18 +34,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
km := m.keymap
|
||||
switch {
|
||||
case key.Matches(msg, km.Select):
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
m.selected = m.table.SelectedRow()
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Quit):
|
||||
case "ctrl+c", "q", "esc":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Abort):
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,23 +53,5 @@ func (m model) View() string {
|
|||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
s := m.table.View()
|
||||
if m.showHelp {
|
||||
s += "\n" + m.countView() + m.help.View(m.keymap)
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(s)
|
||||
}
|
||||
|
||||
func numLen(i int) int {
|
||||
if i == 0 {
|
||||
return 1
|
||||
}
|
||||
count := 0
|
||||
for i != 0 {
|
||||
i /= 10
|
||||
count++
|
||||
}
|
||||
return count
|
||||
return m.table.View()
|
||||
}
|
||||
|
|
|
|||
48
timeout/options.go
Normal file
48
timeout/options.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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())))
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// Package version the version command.
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
// Run check that a given version matches a semantic version constraint.
|
||||
func (o Options) Run(ctx *kong.Context) error {
|
||||
c, err := semver.NewConstraint(o.Constraint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse range %s: %w", o.Constraint, err)
|
||||
}
|
||||
current := ctx.Model.Vars()["versionNumber"]
|
||||
v, err := semver.NewVersion(current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse version %s: %w", current, err)
|
||||
}
|
||||
if !c.Check(v) {
|
||||
return fmt.Errorf("gum version %q is not within given range %q", current, o.Constraint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package version
|
||||
|
||||
// Options is the set of options that can be used with version.
|
||||
type Options struct {
|
||||
Constraint string `arg:"" help:"Semantic version constraint"`
|
||||
}
|
||||
|
|
@ -1,87 +1,54 @@
|
|||
package write
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/gum/cursor"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/internal/timeout"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// Run provides a shell script interface for the text area bubble.
|
||||
// https://github.com/charmbracelet/bubbles/textarea
|
||||
func (o Options) Run() error {
|
||||
in, _ := stdin.Read(stdin.StripANSI(o.StripANSI))
|
||||
in, _ := stdin.Read()
|
||||
if in != "" && o.Value == "" {
|
||||
o.Value = strings.ReplaceAll(in, "\r", "")
|
||||
}
|
||||
|
||||
a := textarea.New()
|
||||
a.Focus()
|
||||
var value = o.Value
|
||||
|
||||
a.Prompt = o.Prompt
|
||||
a.Placeholder = o.Placeholder
|
||||
a.ShowLineNumbers = o.ShowLineNumbers
|
||||
a.CharLimit = o.CharLimit
|
||||
a.MaxHeight = o.MaxLines
|
||||
top, right, bottom, left := style.ParsePadding(o.Padding)
|
||||
theme := huh.ThemeCharm()
|
||||
theme.Focused.Base = o.BaseStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Cursor = o.CursorStyle.ToLipgloss()
|
||||
theme.Focused.Title = o.HeaderStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Placeholder = o.PlaceholderStyle.ToLipgloss()
|
||||
theme.Focused.TextInput.Prompt = o.PromptStyle.ToLipgloss()
|
||||
|
||||
style := textarea.Style{
|
||||
Base: o.BaseStyle.ToLipgloss(),
|
||||
Placeholder: o.PlaceholderStyle.ToLipgloss(),
|
||||
CursorLine: o.CursorLineStyle.ToLipgloss(),
|
||||
CursorLineNumber: o.CursorLineNumberStyle.ToLipgloss(),
|
||||
EndOfBuffer: o.EndOfBufferStyle.ToLipgloss(),
|
||||
LineNumber: o.LineNumberStyle.ToLipgloss(),
|
||||
Prompt: o.PromptStyle.ToLipgloss(),
|
||||
}
|
||||
keymap := huh.NewDefaultKeyMap()
|
||||
keymap.Text.NewLine.SetHelp("ctrl+j", "new line")
|
||||
|
||||
a.BlurredStyle = style
|
||||
a.FocusedStyle = style
|
||||
a.Cursor.Style = o.CursorStyle.ToLipgloss()
|
||||
a.Cursor.SetMode(cursor.Modes[o.CursorMode])
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title(o.Header).
|
||||
Placeholder(o.Placeholder).
|
||||
CharLimit(o.CharLimit).
|
||||
ShowLineNumbers(o.ShowLineNumbers).
|
||||
Value(&value),
|
||||
),
|
||||
).
|
||||
WithWidth(o.Width).
|
||||
WithHeight(o.Height).
|
||||
WithTheme(theme).
|
||||
WithKeyMap(keymap).
|
||||
WithShowHelp(o.ShowHelp).
|
||||
Run()
|
||||
|
||||
a.SetWidth(max(0, o.Width-left-right))
|
||||
a.SetHeight(max(0, o.Height-top-bottom))
|
||||
a.SetValue(o.Value)
|
||||
|
||||
m := model{
|
||||
textarea: a,
|
||||
header: o.Header,
|
||||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
autoWidth: o.Width < 1,
|
||||
help: help.New(),
|
||||
showHelp: o.ShowHelp,
|
||||
keymap: defaultKeymap(),
|
||||
padding: []int{top, right, bottom, left},
|
||||
}
|
||||
|
||||
m.textarea.KeyMap.InsertNewline = m.keymap.InsertNewline
|
||||
|
||||
ctx, cancel := timeout.Context(o.Timeout)
|
||||
defer cancel()
|
||||
|
||||
p := tea.NewProgram(
|
||||
m,
|
||||
tea.WithOutput(os.Stderr),
|
||||
tea.WithReportFocus(),
|
||||
tea.WithContext(ctx),
|
||||
)
|
||||
tm, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run write: %w", err)
|
||||
return err
|
||||
}
|
||||
m = tm.(model)
|
||||
if !m.submitted {
|
||||
return errors.New("not submitted")
|
||||
}
|
||||
fmt.Println(m.textarea.Value())
|
||||
|
||||
fmt.Println(value)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,29 @@
|
|||
package write
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/gum/style"
|
||||
)
|
||||
import "github.com/charmbracelet/gum/style"
|
||||
|
||||
// Options are the customization options for the textarea.
|
||||
type Options struct {
|
||||
Width int `help:"Text area width (0 for terminal width)" default:"0" env:"GUM_WRITE_WIDTH"`
|
||||
Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"`
|
||||
Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"┃ " env:"GUM_WRITE_PROMPT"`
|
||||
ShowCursorLine bool `help:"Show cursor line" default:"false" env:"GUM_WRITE_SHOW_CURSOR_LINE"`
|
||||
ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"`
|
||||
Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"`
|
||||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"0"`
|
||||
MaxLines int `help:"Maximum number of lines (0 for no limit)" default:"0"`
|
||||
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_WRITE_SHOW_HELP"`
|
||||
CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_WRITE_CURSOR_MODE"`
|
||||
Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_WRITE_TIMEOUT"`
|
||||
StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_WRITE_STRIP_ANSI"`
|
||||
Width int `help:"Text area width (0 for terminal width)" default:"0" env:"GUM_WRITE_WIDTH"`
|
||||
Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"`
|
||||
Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"┃ " env:"GUM_WRITE_PROMPT"`
|
||||
ShowCursorLine bool `help:"Show cursor line" default:"false" env:"GUM_WRITE_SHOW_CURSOR_LINE"`
|
||||
ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"`
|
||||
Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"`
|
||||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
|
||||
ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_WRITE_SHOW_HELP"`
|
||||
CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_WRITE_CURSOR_MODE"`
|
||||
|
||||
BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"`
|
||||
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"`
|
||||
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"`
|
||||
|
||||
BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"`
|
||||
CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"`
|
||||
CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"`
|
||||
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"`
|
||||
EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"`
|
||||
LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"`
|
||||
PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"`
|
||||
Padding string `help:"Padding" default:"${defaultPadding}" group:"Style Flags" env:"GUM_WRITE_PADDING"`
|
||||
CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"`
|
||||
CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"`
|
||||
}
|
||||
|
|
|
|||
202
write/write.go
202
write/write.go
|
|
@ -1,202 +0,0 @@
|
|||
// Package write provides a shell script interface for the text area bubble.
|
||||
// https://github.com/charmbracelet/bubbles/tree/master/textarea
|
||||
//
|
||||
// It can be used to ask the user to write some long form of text (multi-line)
|
||||
// input. The text the user entered will be sent to stdout.
|
||||
// Text entry is completed with CTRL+D and aborted with CTRL+C or Escape.
|
||||
//
|
||||
// $ gum write > output.text
|
||||
package write
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/editor"
|
||||
)
|
||||
|
||||
type keymap struct {
|
||||
textarea.KeyMap
|
||||
Submit key.Binding
|
||||
Quit key.Binding
|
||||
Abort key.Binding
|
||||
OpenInEditor key.Binding
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
func (k keymap) FullHelp() [][]key.Binding { return nil }
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k keymap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.InsertNewline,
|
||||
k.OpenInEditor,
|
||||
k.Submit,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultKeymap() keymap {
|
||||
km := textarea.DefaultKeyMap
|
||||
km.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j"),
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
return keymap{
|
||||
KeyMap: km,
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
Abort: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "cancel"),
|
||||
),
|
||||
OpenInEditor: key.NewBinding(
|
||||
key.WithKeys("ctrl+e"),
|
||||
key.WithHelp("ctrl+e", "open editor"),
|
||||
),
|
||||
Submit: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
autoWidth bool
|
||||
header string
|
||||
headerStyle lipgloss.Style
|
||||
quitting bool
|
||||
submitted bool
|
||||
textarea textarea.Model
|
||||
showHelp bool
|
||||
help help.Model
|
||||
keymap keymap
|
||||
padding []int
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return textarea.Blink }
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Display the header above the text area if it is not empty.
|
||||
if m.header != "" {
|
||||
parts = append(parts, m.headerStyle.Render(m.header))
|
||||
}
|
||||
parts = append(parts, m.textarea.View())
|
||||
if m.showHelp {
|
||||
parts = append(parts, "", m.help.View(m.keymap))
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Padding(m.padding...).
|
||||
Render(lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
))
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if m.autoWidth {
|
||||
m.textarea.SetWidth(msg.Width - m.padding[1] - m.padding[3])
|
||||
}
|
||||
case tea.FocusMsg, tea.BlurMsg:
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
case startEditorMsg:
|
||||
return m, openEditor(msg.path, msg.lineno)
|
||||
case editorFinishedMsg:
|
||||
if msg.err != nil {
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
}
|
||||
m.textarea.SetValue(msg.content)
|
||||
case tea.KeyMsg:
|
||||
km := m.keymap
|
||||
switch {
|
||||
case key.Matches(msg, km.Abort):
|
||||
m.quitting = true
|
||||
return m, tea.Interrupt
|
||||
case key.Matches(msg, km.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.Submit):
|
||||
m.quitting = true
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, km.OpenInEditor):
|
||||
return m, createTempFile(m.textarea.Value(), m.textarea.Line()+1)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
type startEditorMsg struct {
|
||||
path string
|
||||
lineno int
|
||||
}
|
||||
|
||||
type editorFinishedMsg struct {
|
||||
content string
|
||||
err error
|
||||
}
|
||||
|
||||
func createTempFile(content string, lineno int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
f, err := os.CreateTemp("", "gum.*.md")
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
_, err = io.WriteString(f, content)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
_ = f.Close()
|
||||
return startEditorMsg{
|
||||
path: f.Name(),
|
||||
lineno: lineno,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openEditor(path string, lineno int) tea.Cmd {
|
||||
cb := func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return editorFinishedMsg{
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
bts, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
return editorFinishedMsg{
|
||||
content: string(bts),
|
||||
}
|
||||
}
|
||||
cmd, err := editor.Cmd(
|
||||
"Gum",
|
||||
path,
|
||||
editor.LineNumber(lineno),
|
||||
editor.EndOfLine(),
|
||||
)
|
||||
if err != nil {
|
||||
return func() tea.Msg { return cb(err) }
|
||||
}
|
||||
return tea.ExecProcess(cmd, cb)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue