mirror of
https://github.com/charmbracelet/gum
synced 2024-06-17 04:55:05 +02:00
Merge branch 'main' into feature/218/Adding_timeout_to_most_commands
# Conflicts: # filter/command.go # filter/filter.go # filter/options.go # go.sum # input/command.go # input/input.go # input/options.go # pager/command.go # pager/options.go # pager/pager.go
This commit is contained in:
commit
3bbb56821d
12
.github/workflows/soft-serve.yml
vendored
12
.github/workflows/soft-serve.yml
vendored
|
@ -1,12 +0,0 @@
|
|||
name: soft-serve
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
soft-serve:
|
||||
uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
|
||||
secrets:
|
||||
ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Charmbracelet, Inc
|
||||
Copyright (c) 2022-2023 Charmbracelet, Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
@ -19,7 +19,6 @@ import (
|
|||
"github.com/charmbracelet/bubbles/paginator"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
|
@ -178,7 +177,7 @@ func (m model) View() string {
|
|||
if i == m.index%m.height {
|
||||
s.WriteString(m.cursorStyle.Render(m.cursor))
|
||||
} else {
|
||||
s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.cursor)))
|
||||
s.WriteString(strings.Repeat(" ", lipgloss.Width(m.cursor)))
|
||||
}
|
||||
|
||||
if item.selected {
|
||||
|
|
12
cursor/cursor.go
Normal file
12
cursor/cursor.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/cursor"
|
||||
)
|
||||
|
||||
// Modes maps strings to cursor modes.
|
||||
var Modes = map[string]cursor.Mode{
|
||||
"blink": cursor.CursorBlink,
|
||||
"hide": cursor.CursorHide,
|
||||
"static": cursor.CursorStatic,
|
||||
}
|
47
examples/kaomoji.sh
Normal file
47
examples/kaomoji.sh
Normal file
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# If the user passes '-h', '--help', or 'help' print out a little bit of help.
|
||||
# text.
|
||||
case "$1" in
|
||||
"-h" | "--help" | "help")
|
||||
printf 'Generate kaomojis on request.\n\n'
|
||||
printf 'Usage: %s [kind]\n' "$(basename "$0")"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# The user can pass an argument like "bear" or "angry" to specify the general
|
||||
# kind of Kaomoji produced.
|
||||
sentiment=""
|
||||
if [[ $1 != "" ]]; then
|
||||
sentiment=" $1"
|
||||
fi
|
||||
|
||||
# Ask mods to generate Kaomojis. Save the output in a variable.
|
||||
kaomoji="$(mods "generate 10${sentiment} kaomojis. number them and put each one on its own line.")"
|
||||
if [[ $kaomoji == "" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pipe mods output to gum so the user can choose the perfect kaomoji. Save that
|
||||
# choice in a variable. Also note that we're using cut to drop the item number
|
||||
# in front of the Kaomoji.
|
||||
choice="$(echo "$kaomoji" | gum choose | cut -d ' ' -f 2)"
|
||||
if [[ $choice == "" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If xsel (X11) or pbcopy (macOS) exists, copy to the clipboard. If not, just
|
||||
# print the Kaomoji.
|
||||
if command -v xsel &> /dev/null; then
|
||||
printf '%s' "$choice" | xclip -sel clip # X11
|
||||
elif command -v pbcopy &> /dev/null; then
|
||||
printf '%s' "$choice" | pbcopy # macOS
|
||||
else
|
||||
# We can't copy, so just print it out.
|
||||
printf 'Here you go: %s\n' "$choice"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# We're done!
|
||||
printf 'Copied %s to the clipboard\n' "$choice"
|
|
@ -38,15 +38,14 @@ func (o Options) Run() error {
|
|||
fp.DirAllowed = o.Directory
|
||||
fp.FileAllowed = o.File
|
||||
fp.ShowHidden = o.All
|
||||
fp.Styles = filepicker.Styles{
|
||||
Cursor: o.CursorStyle.ToLipgloss(),
|
||||
Symlink: o.SymlinkStyle.ToLipgloss(),
|
||||
Directory: o.DirectoryStyle.ToLipgloss(),
|
||||
File: o.FileStyle.ToLipgloss(),
|
||||
Permission: o.PermissionsStyle.ToLipgloss(),
|
||||
Selected: o.SelectedStyle.ToLipgloss(),
|
||||
FileSize: o.FileSizeStyle.ToLipgloss(),
|
||||
}
|
||||
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()
|
||||
|
||||
m := model{
|
||||
filepicker: fp,
|
||||
|
|
|
@ -91,6 +91,7 @@ func (o Options) Run() error {
|
|||
fuzzy: o.Fuzzy,
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
sort: o.Sort,
|
||||
}, options...)
|
||||
|
||||
tm, err := p.Run()
|
||||
|
|
|
@ -20,7 +20,6 @@ import (
|
|||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
|
@ -50,6 +49,7 @@ type model struct {
|
|||
fuzzy bool
|
||||
timeout time.Duration
|
||||
hasTimeout bool
|
||||
sort bool
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
|
@ -84,7 +84,7 @@ func (m model) View() string {
|
|||
if i == m.cursor {
|
||||
s.WriteString(m.indicatorStyle.Render(m.indicator))
|
||||
} else {
|
||||
s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator)))
|
||||
s.WriteString(strings.Repeat(" ", lipgloss.Width(m.indicator)))
|
||||
}
|
||||
|
||||
// If there are multiple selections mark them, otherwise leave an empty space
|
||||
|
@ -217,7 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
// A character was entered, this likely means that the text input has
|
||||
// changed. This suggests that the matches are outdated, so update them.
|
||||
if m.fuzzy {
|
||||
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
|
||||
if m.sort {
|
||||
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
|
||||
} else {
|
||||
m.matches = fuzzy.FindNoSort(m.textinput.Value(), m.choices)
|
||||
}
|
||||
} else {
|
||||
m.matches = exactMatches(m.textinput.Value(), m.choices)
|
||||
}
|
||||
|
|
|
@ -8,26 +8,28 @@ import (
|
|||
|
||||
// Options is the customization options for the filter command.
|
||||
type Options struct {
|
||||
Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"`
|
||||
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"`
|
||||
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
|
||||
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
|
||||
Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" default:"true" group:"Selection"`
|
||||
SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"`
|
||||
SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"`
|
||||
UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"`
|
||||
UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"`
|
||||
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_"`
|
||||
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"`
|
||||
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"`
|
||||
Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"`
|
||||
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"`
|
||||
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"`
|
||||
Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"`
|
||||
Fuzzy bool `help:"Enable fuzzy matching" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
|
||||
Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"`
|
||||
IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"`
|
||||
Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"`
|
||||
NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"`
|
||||
Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"true" default:"true" group:"Selection"`
|
||||
SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"`
|
||||
SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"`
|
||||
UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"`
|
||||
UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"`
|
||||
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_"`
|
||||
MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"`
|
||||
Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"`
|
||||
Width int `help:"Input width" default:"20" env:"GUM_FILTER_WIDTH"`
|
||||
Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"`
|
||||
Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"`
|
||||
Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"`
|
||||
Fuzzy bool `help:"Enable fuzzy matching" default:"true" env:"GUM_FILTER_FUZZY" negatable:""`
|
||||
Timeout time.Duration `help:"Timeout until filter command aborts" default:"0" env:"GUM_FILTER_TIMEOUT"`
|
||||
Sort bool `help:"Sort the results" default:"true" env:"GUM_FILTER_SORT" negatable:""`
|
||||
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ package format
|
|||
// Options is customization options for the format command.
|
||||
type Options struct {
|
||||
Template []string `arg:"" optional:"" help:"Template string to format (can also be provided via stdin)"`
|
||||
Theme string `help:"Glamour theme to use for markdown formatting" default:"pink"`
|
||||
Language string `help:"Programming language to parse code" short:"l" default:""`
|
||||
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"`
|
||||
|
||||
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown"`
|
||||
Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"`
|
||||
}
|
||||
|
|
14
go.mod
14
go.mod
|
@ -5,19 +5,19 @@ go 1.18
|
|||
require (
|
||||
github.com/alecthomas/kong v0.7.1
|
||||
github.com/alecthomas/mango-kong v0.1.0
|
||||
github.com/charmbracelet/bubbles v0.15.1-0.20230324185713-1de5816ab4f7
|
||||
github.com/charmbracelet/bubbletea v0.23.3-0.20230316100943-248eb83001a7
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/bubbles v0.16.1
|
||||
github.com/charmbracelet/bubbletea v0.24.2
|
||||
github.com/charmbracelet/glamour v0.6.1-0.20230531150759-6d5b52861a9d
|
||||
github.com/charmbracelet/lipgloss v0.7.2-0.20230316100548-06dd20ee5707
|
||||
github.com/mattn/go-isatty v0.0.18
|
||||
github.com/mattn/go-runewidth v0.0.14
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/roff v0.1.0
|
||||
github.com/muesli/termenv v0.15.2-0.20230323153104-73a40463ff25
|
||||
github.com/sahilm/fuzzy v0.1.0
|
||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma v0.10.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.7.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
|
@ -27,11 +27,11 @@ require (
|
|||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.23 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/charmbracelet/gum/cursor"
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
|
@ -29,6 +30,7 @@ func (o Options) Run() error {
|
|||
i.Width = o.Width
|
||||
i.PromptStyle = o.PromptStyle.ToLipgloss()
|
||||
i.Cursor.Style = o.CursorStyle.ToLipgloss()
|
||||
i.Cursor.SetMode(cursor.Modes[o.CursorMode])
|
||||
i.CharLimit = o.CharLimit
|
||||
|
||||
if o.Password {
|
||||
|
@ -43,6 +45,7 @@ func (o Options) Run() error {
|
|||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
autoWidth: o.Width < 1,
|
||||
}, tea.WithOutput(os.Stderr))
|
||||
tm, err := p.Run()
|
||||
if err != nil {
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
)
|
||||
|
||||
type model struct {
|
||||
autoWidth bool
|
||||
header string
|
||||
headerStyle lipgloss.Style
|
||||
textinput textinput.Model
|
||||
|
@ -46,6 +47,10 @@ func (m model) View() string {
|
|||
|
||||
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 - lipgloss.Width(m.textinput.Prompt) - 1
|
||||
}
|
||||
case timeout.TickTimeoutMsg:
|
||||
if msg.TimeoutValue <= 0 {
|
||||
m.quitting = true
|
||||
|
|
|
@ -8,15 +8,16 @@ import (
|
|||
|
||||
// Options are the customization options for the input.
|
||||
type Options struct {
|
||||
Placeholder string `help:"Placeholder value" default:"Type something..." env:"GUM_INPUT_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"> " env:"GUM_INPUT_PROMPT"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." envprefix:"GUM_INPUT_PROMPT_"`
|
||||
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_INPUT_CURSOR_"`
|
||||
Value string `help:"Initial value (can also be passed via stdin)" default:""`
|
||||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
|
||||
Width int `help:"Input width" default:"40" env:"GUM_INPUT_WIDTH"`
|
||||
Password bool `help:"Mask input characters" default:"false"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"`
|
||||
Placeholder string `help:"Placeholder value" default:"Type something..." env:"GUM_INPUT_PLACEHOLDER"`
|
||||
Prompt string `help:"Prompt to display" default:"> " env:"GUM_INPUT_PROMPT"`
|
||||
PromptStyle style.Styles `embed:"" prefix:"prompt." envprefix:"GUM_INPUT_PROMPT_"`
|
||||
CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_INPUT_CURSOR_"`
|
||||
CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_INPUT_CURSOR_MODE"`
|
||||
Value string `help:"Initial value (can also be passed via stdin)" default:""`
|
||||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
|
||||
Width int `help:"Input width" default:"40" env:"GUM_INPUT_WIDTH"`
|
||||
Password bool `help:"Mask input characters" default:"false"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"`
|
||||
HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"`
|
||||
Timeout time.Duration `help:"Timeout until input aborts" default:"0" env:"GUM_INPUT_TIMEOUT"`
|
||||
}
|
||||
|
|
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
|
||||
}
|
|
@ -16,7 +16,7 @@ func (m Man) BeforeApply(ctx *kong.Context) error {
|
|||
// Set the correct man pages description without color escape sequences.
|
||||
ctx.Model.Help = "A tool for glamorous shell scripts."
|
||||
man := mangokong.NewManPage(1, ctx.Model)
|
||||
man = man.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
|
||||
man = man.WithSection("Copyright", "(C) 2022-2023 Charmbracelet, Inc.\n"+
|
||||
"Released under MIT license.")
|
||||
fmt.Fprint(ctx.Stdout, man.Build(roff.NewDocument()))
|
||||
ctx.Exit(0)
|
||||
|
|
|
@ -35,9 +35,12 @@ func (o Options) Run() error {
|
|||
viewport: vp,
|
||||
helpStyle: o.HelpStyle.ToLipgloss(),
|
||||
content: o.Content,
|
||||
origContent: o.Content,
|
||||
showLineNumbers: o.ShowLineNumbers,
|
||||
lineNumberStyle: o.LineNumberStyle.ToLipgloss(),
|
||||
softWrap: o.SoftWrap,
|
||||
matchStyle: o.MatchStyle.ToLipgloss(),
|
||||
matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(),
|
||||
timeout: o.Timeout,
|
||||
hasTimeout: o.Timeout > 0,
|
||||
}
|
||||
|
|
|
@ -9,11 +9,13 @@ import (
|
|||
// Options are the options for the pager.
|
||||
type Options struct {
|
||||
//nolint:staticcheck
|
||||
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
|
||||
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
|
||||
Content string `arg:"" optional:"" help:"Display content to scroll"`
|
||||
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
|
||||
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
|
||||
SoftWrap bool `help:"Soft wrap lines" default:"false"`
|
||||
Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"`
|
||||
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
|
||||
Content string `arg:"" optional:"" help:"Display content to scroll"`
|
||||
ShowLineNumbers bool `help:"Show line numbers" default:"true"`
|
||||
LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"`
|
||||
SoftWrap bool `help:"Soft wrap lines" default:"false"`
|
||||
MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck
|
||||
MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck
|
||||
Timeout time.Duration `help:"Timeout until command exits" default:"0" env:"GUM_PAGER_TIMEOUT"`
|
||||
}
|
||||
|
|
141
pager/pager.go
141
pager/pager.go
|
@ -13,16 +13,21 @@ import (
|
|||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
content string
|
||||
origContent string
|
||||
viewport viewport.Model
|
||||
helpStyle lipgloss.Style
|
||||
showLineNumbers bool
|
||||
lineNumberStyle lipgloss.Style
|
||||
softWrap bool
|
||||
search search
|
||||
matchStyle lipgloss.Style
|
||||
matchHighlightStyle lipgloss.Style
|
||||
maxWidth int
|
||||
timeout time.Duration
|
||||
hasTimeout bool
|
||||
}
|
||||
|
@ -41,57 +46,96 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, timeout.Tick(msg.TimeoutValue, msg.Data)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1
|
||||
m.viewport.Width = msg.Width
|
||||
textStyle := lipgloss.NewStyle().Width(m.viewport.Width)
|
||||
var text strings.Builder
|
||||
|
||||
// Determine max width of a line
|
||||
maxLineWidth := m.viewport.Width
|
||||
if m.softWrap {
|
||||
vpStyle := m.viewport.Style
|
||||
maxLineWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
|
||||
if m.showLineNumbers {
|
||||
maxLineWidth -= len(" │ ")
|
||||
}
|
||||
}
|
||||
|
||||
for i, line := range strings.Split(m.content, "\n") {
|
||||
line = strings.ReplaceAll(line, "\t", " ")
|
||||
if m.showLineNumbers {
|
||||
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
|
||||
}
|
||||
for m.softWrap && len(line) > maxLineWidth {
|
||||
truncatedLine := runewidth.Truncate(line, maxLineWidth, "")
|
||||
text.WriteString(textStyle.Render(truncatedLine))
|
||||
text.WriteString("\n")
|
||||
if m.showLineNumbers {
|
||||
text.WriteString(m.lineNumberStyle.Render(" │ "))
|
||||
}
|
||||
line = strings.Replace(line, truncatedLine, "", 1)
|
||||
}
|
||||
text.WriteString(textStyle.Render(runewidth.Truncate(line, maxLineWidth, "")))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
|
||||
diffHeight := m.viewport.Height - lipgloss.Height(text.String())
|
||||
if diffHeight > 0 && m.showLineNumbers {
|
||||
remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1)
|
||||
text.WriteString(m.lineNumberStyle.Render(remainingLines))
|
||||
}
|
||||
m.viewport.SetContent(text.String())
|
||||
m.ProcessText(msg)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
return m.KeyHandler(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) ProcessText(msg tea.WindowSizeMsg) {
|
||||
m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1
|
||||
m.viewport.Width = msg.Width
|
||||
textStyle := lipgloss.NewStyle().Width(m.viewport.Width)
|
||||
var text strings.Builder
|
||||
|
||||
// Determine max width of a line.
|
||||
m.maxWidth = m.viewport.Width
|
||||
if m.softWrap {
|
||||
vpStyle := m.viewport.Style
|
||||
m.maxWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
|
||||
if m.showLineNumbers {
|
||||
m.maxWidth -= lipgloss.Width(" │ ")
|
||||
}
|
||||
}
|
||||
|
||||
for i, line := range strings.Split(m.content, "\n") {
|
||||
line = strings.ReplaceAll(line, "\t", " ")
|
||||
if m.showLineNumbers {
|
||||
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
|
||||
}
|
||||
for m.softWrap && lipgloss.Width(line) > m.maxWidth {
|
||||
truncatedLine := truncate.String(line, uint(m.maxWidth))
|
||||
text.WriteString(textStyle.Render(truncatedLine))
|
||||
text.WriteString("\n")
|
||||
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())
|
||||
if diffHeight > 0 && m.showLineNumbers {
|
||||
remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1)
|
||||
text.WriteString(m.lineNumberStyle.Render(remainingLines))
|
||||
}
|
||||
m.viewport.SetContent(text.String())
|
||||
}
|
||||
|
||||
func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) {
|
||||
var cmd tea.Cmd
|
||||
if m.search.active {
|
||||
switch key.String() {
|
||||
case "enter":
|
||||
if m.search.input.Value() != "" {
|
||||
m.content = m.origContent
|
||||
m.search.Execute(&m)
|
||||
|
||||
// Trigger a view update to highlight the found matches.
|
||||
m.search.NextMatch(&m)
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + 2, Width: m.viewport.Width})
|
||||
} else {
|
||||
m.search.Done()
|
||||
}
|
||||
case "ctrl+d", "ctrl+c", "esc":
|
||||
m.search.Done()
|
||||
default:
|
||||
m.search.input, cmd = m.search.input.Update(key)
|
||||
}
|
||||
} else {
|
||||
switch key.String() {
|
||||
case "g":
|
||||
m.viewport.GotoTop()
|
||||
case "G":
|
||||
m.viewport.GotoBottom()
|
||||
case "/":
|
||||
m.search.Begin()
|
||||
case "p", "N":
|
||||
m.search.PrevMatch(&m)
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + 2, Width: m.viewport.Width})
|
||||
case "n":
|
||||
m.search.NextMatch(&m)
|
||||
m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + 2, Width: m.viewport.Width})
|
||||
case "q", "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.viewport, cmd = m.viewport.Update(key)
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
|
@ -100,5 +144,14 @@ func (m model) View() string {
|
|||
if m.hasTimeout {
|
||||
timeoutStr = timeout.Str(m.timeout) + " "
|
||||
}
|
||||
return m.viewport.View() + m.helpStyle.Render("\n"+timeoutStr+"↑/↓: Navigate • q: Quit")
|
||||
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() + m.helpStyle.Render(helpMsg)
|
||||
}
|
||||
|
|
164
pager/search.go
Normal file
164
pager/search.go
Normal file
|
@ -0,0 +1,164 @@
|
|||
package pager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/gum/internal/utils"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
type search struct {
|
||||
active bool
|
||||
input textinput.Model
|
||||
query *regexp.Regexp
|
||||
matchIndex int
|
||||
matchLipglossStr string
|
||||
matchString string
|
||||
}
|
||||
|
||||
func (s *search) new() {
|
||||
input := textinput.New()
|
||||
input.Placeholder = "search"
|
||||
input.Prompt = "/"
|
||||
input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
s.input = input
|
||||
}
|
||||
|
||||
func (s *search) Begin() {
|
||||
s.new()
|
||||
s.active = true
|
||||
s.input.Focus()
|
||||
}
|
||||
|
||||
// Execute find all lines in the model with a match.
|
||||
func (s *search) Execute(m *model) {
|
||||
defer s.Done()
|
||||
if s.input.Value() == "" {
|
||||
s.query = nil
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
s.query, err = regexp.Compile(s.input.Value())
|
||||
if err != nil {
|
||||
s.query = nil
|
||||
return
|
||||
}
|
||||
query := regexp.MustCompile(fmt.Sprintf("(%s)", s.query.String()))
|
||||
m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1"))
|
||||
|
||||
// Recompile the regex to match the an replace the highlights.
|
||||
leftPad, _ := utils.LipglossPadding(m.matchStyle)
|
||||
matchingString := regexp.QuoteMeta(m.matchStyle.Render()[:leftPad]) + s.query.String() + regexp.QuoteMeta(m.matchStyle.Render()[leftPad:])
|
||||
s.query, err = regexp.Compile(matchingString)
|
||||
if err != nil {
|
||||
s.query = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *search) Done() {
|
||||
s.active = false
|
||||
|
||||
// To account for the first match is always executed.
|
||||
s.matchIndex = -1
|
||||
}
|
||||
|
||||
func (s *search) NextMatch(m *model) {
|
||||
// Check that we are within bounds.
|
||||
if s.query == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove previous highlight.
|
||||
m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1)
|
||||
|
||||
// Highlight the next match.
|
||||
allMatches := s.query.FindAllStringIndex(m.content, -1)
|
||||
if len(allMatches) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
|
||||
s.matchIndex = (s.matchIndex + 1) % len(allMatches)
|
||||
match := allMatches[s.matchIndex]
|
||||
lhs := m.content[:match[0]]
|
||||
rhs := m.content[match[0]:]
|
||||
s.matchString = m.content[match[0]:match[1]]
|
||||
s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad])
|
||||
m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1)
|
||||
|
||||
// Update the viewport position.
|
||||
var line int
|
||||
formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap)
|
||||
index := strings.Index(formatStr, s.matchLipglossStr)
|
||||
if index != -1 {
|
||||
line = strings.Count(formatStr[:index], "\n")
|
||||
}
|
||||
|
||||
// Only update if the match is not within the viewport.
|
||||
if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) {
|
||||
m.viewport.SetYOffset(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *search) PrevMatch(m *model) {
|
||||
// Check that we are within bounds.
|
||||
if s.query == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove previous highlight.
|
||||
m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1)
|
||||
|
||||
// Highlight the previous match.
|
||||
allMatches := s.query.FindAllStringIndex(m.content, -1)
|
||||
if len(allMatches) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.matchIndex = (s.matchIndex - 1) % len(allMatches)
|
||||
if s.matchIndex < 0 {
|
||||
s.matchIndex = len(allMatches) - 1
|
||||
}
|
||||
|
||||
leftPad, rightPad := utils.LipglossPadding(m.matchStyle)
|
||||
match := allMatches[s.matchIndex]
|
||||
lhs := m.content[:match[0]]
|
||||
rhs := m.content[match[0]:]
|
||||
s.matchString = m.content[match[0]:match[1]]
|
||||
s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad])
|
||||
m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1)
|
||||
|
||||
// Update the viewport position.
|
||||
var line int
|
||||
formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap)
|
||||
index := strings.Index(formatStr, s.matchLipglossStr)
|
||||
if index != -1 {
|
||||
line = strings.Count(formatStr[:index], "\n")
|
||||
}
|
||||
|
||||
// Only update if the match is not within the viewport.
|
||||
if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) {
|
||||
m.viewport.SetYOffset(line)
|
||||
}
|
||||
}
|
||||
|
||||
func softWrapEm(str string, maxWidth int, softWrap bool) string {
|
||||
var text strings.Builder
|
||||
for _, line := range strings.Split(str, "\n") {
|
||||
for softWrap && lipgloss.Width(line) > maxWidth {
|
||||
truncatedLine := truncate.String(line, uint(maxWidth))
|
||||
text.WriteString(truncatedLine)
|
||||
text.WriteString("\n")
|
||||
line = strings.Replace(line, truncatedLine, "", 1)
|
||||
}
|
||||
text.WriteString(truncate.String(line, uint(maxWidth)))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
|
||||
return text.String()
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/alecthomas/kong"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
|
@ -59,7 +59,7 @@ func (o Options) Run() error {
|
|||
var columns = make([]table.Column, 0, len(columnNames))
|
||||
|
||||
for i, title := range columnNames {
|
||||
width := runewidth.StringWidth(title)
|
||||
width := lipgloss.Width(title)
|
||||
if len(o.Widths) > i {
|
||||
width = o.Widths[i]
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/charmbracelet/gum/cursor"
|
||||
"github.com/charmbracelet/gum/internal/exit"
|
||||
"github.com/charmbracelet/gum/internal/stdin"
|
||||
"github.com/charmbracelet/gum/style"
|
||||
|
@ -43,6 +44,7 @@ func (o Options) Run() error {
|
|||
a.BlurredStyle = style
|
||||
a.FocusedStyle = style
|
||||
a.Cursor.Style = o.CursorStyle.ToLipgloss()
|
||||
a.Cursor.SetMode(cursor.Modes[o.CursorMode])
|
||||
|
||||
a.SetWidth(o.Width)
|
||||
a.SetHeight(o.Height)
|
||||
|
@ -52,6 +54,7 @@ func (o Options) Run() error {
|
|||
textarea: a,
|
||||
header: o.Header,
|
||||
headerStyle: o.HeaderStyle.ToLipgloss(),
|
||||
autoWidth: o.Width < 1,
|
||||
}, tea.WithOutput(os.Stderr))
|
||||
tm, err := p.Run()
|
||||
if err != nil {
|
||||
|
|
|
@ -4,7 +4,7 @@ import "github.com/charmbracelet/gum/style"
|
|||
|
||||
// Options are the customization options for the textarea.
|
||||
type Options struct {
|
||||
Width int `help:"Text area width" default:"50" env:"GUM_WRITE_WIDTH"`
|
||||
Width int `help:"Text area width (0 for terminal width)" default:"50" env:"GUM_WRITE_WIDTH"`
|
||||
Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"`
|
||||
Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"`
|
||||
Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"`
|
||||
|
@ -13,6 +13,7 @@ type Options struct {
|
|||
ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"`
|
||||
Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"`
|
||||
CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"`
|
||||
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_"`
|
||||
CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"`
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
)
|
||||
|
||||
type model struct {
|
||||
autoWidth bool
|
||||
aborted bool
|
||||
header string
|
||||
headerStyle lipgloss.Style
|
||||
|
@ -39,6 +40,10 @@ func (m model) View() string {
|
|||
|
||||
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)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
|
|
Loading…
Reference in a new issue