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:
Dieter Eickstaedt 2023-06-26 09:24:22 +02:00
commit 3bbb56821d
25 changed files with 441 additions and 1089 deletions

View file

@ -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 }}"

View file

@ -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

View file

@ -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
View 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
View 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"

View file

@ -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,

View file

@ -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()

View file

@ -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)
}

View file

@ -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:""`
}

View file

@ -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
View file

@ -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

979
go.sum

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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

View file

@ -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
View 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
}

View file

@ -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)

View file

@ -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,
}

View file

@ -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"`
}

View file

@ -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
View 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()
}

View file

@ -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]
}

View file

@ -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 {

View file

@ -4,7 +4,7 @@ import "github.com/charmbracelet/gum/style"
// Options are the customization options for the textarea.
type Options struct {
Width int `help:"Text area width" 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_"`

View file

@ -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":