66 Commits

Author SHA1 Message Date
Christian Rocha
da9248f877 Update progress bar description and example links 2021-09-07 14:31:40 -04:00
Christian Rocha
b691d7f07e Progress bars now statisfy the tea.Model interface 2021-09-07 13:57:46 -04:00
Christian Rocha
8ad268837a Add image and example link for Help bubble 2021-09-07 13:16:26 -04:00
Christian Rocha
0f290114f5 Add mritd/bubbles to "additional bubbles" in the README 2021-09-02 16:39:17 -04:00
Christian Rocha
500d7989e1 Fix keybinding examples 2021-09-01 11:33:41 -04:00
Christian Muehlhaeuser
d987ef84f2 Fix list description in README 2021-08-23 23:30:54 +02:00
Christian Muehlhaeuser
cb52746b97 Fix typos 2021-08-23 23:15:21 +02:00
Christian Muehlhaeuser
985ffcb319 Bump bubbletea, lipgloss, reflow, termenv deps 2021-08-23 21:45:01 +02:00
Christian Muehlhaeuser
4e18245481 Add list bubble 2021-08-23 21:34:13 +02:00
Christian Rocha
0be588365e Add simple helper package for making user-definable keymappings and help bubble 2021-08-23 17:00:29 +02:00
Christian Rocha
4c32a64d65 Add info about promptkit to README 2021-08-02 12:23:25 -04:00
Christian Rocha
e723cfd15e Add WithSpringOptions to customize spring in NewModel 2021-07-26 12:41:37 -04:00
Christian Rocha
90d8eaffeb Update progress bar with public harmonica release 2021-07-26 12:41:37 -04:00
Christian Rocha
202d5a9f9d Progress's NewModel no longer returns an error for better usability
To that end, bad hex colors also default to black. This is also how we
handle bad hex notation in Lip Gloss.
2021-07-26 12:41:37 -04:00
Christian Rocha
446433d680 Use Lip Gloss to style percentage in progress bubble 2021-07-26 12:41:37 -04:00
Christian Rocha
2cd3b16343 Use Harmonica to animate progress bar 2021-07-26 12:41:37 -04:00
Christian Rocha
5ae3b7b822 Update go-runewidth to v0.0.13 2021-07-26 12:41:37 -04:00
Christian Rocha
20ead8fb7d Progress can now animate itself 2021-07-26 12:41:37 -04:00
Christian Muehlhaeuser
09a4cf419d Update linter config 2021-07-12 03:10:31 +02:00
Christian Rocha
d06aa4aa48 Ensure that textinputs can only receive their own blink messages 2021-06-02 10:49:43 -04:00
Christian Rocha
10962af2f2 Textinput's focus method now returns a command to initiate cursor blink 2021-06-02 10:49:43 -04:00
Christian Rocha
5082ae6f31 Add API for blink, static and hidden cursor modes to textinput
Closes #53.
2021-06-02 10:49:43 -04:00
Christian Rocha
4365990396 Fix typo in a spinner comment 2021-06-02 10:49:43 -04:00
Christian Rocha
1405b09ec8 Update footer in README for consistency with newer Charm repos 2021-06-02 10:49:43 -04:00
Christian Muehlhaeuser
f53c742d6c Fix goreportcard badge 2021-05-18 20:56:27 +02:00
Christian Rocha
9449cc7e41 Don't allow word-based deletion when input is masked in textinput
alt+d and ctrl+w will now delete all the way to the beginning and end,
respectively, if EchoMode is not EchoNormal.
2021-04-26 12:03:57 -04:00
Christian Rocha
58a177394e Don't allow word-to-word movement when input is masked in textinput
When EchoMode is not set to EchoNormal, alt+left/alt+b and
alt+right/alt+b jumps to the beginning and the end, respectively, so as
not to reveal word breaks in the masked text.
2021-04-26 12:03:57 -04:00
Christian Rocha
f016c31d83 Add method for returning cursor position in textinput
Previously, Cursor was a member on the model that did absolutely
nothing.
2021-04-26 12:03:57 -04:00
Christian Rocha
74436326b9 Public cursor movement functions no longer return values in textinput
Previously, textinput.SetCursor, textinput.CursotStart, and
textinput.CursorEnd returned bools used interally for managing cursor
blinking. Those methods have been replaced with private counterparts.
2021-04-26 12:03:57 -04:00
Christian Rocha
158097df66 Keep y-offset in bounds when setting content 2021-04-19 19:47:50 -04:00
Christian Rocha
0d5d7e5acd Remove deadcode + small comment on Lip Gloss 2021-04-13 17:09:23 -04:00
Christian Rocha
ddd48d9ab7 TextInput now uses Lip Gloss for styling 2021-04-13 17:09:23 -04:00
Christian Rocha
7242bbe8dc Spinner now uses Lip Gloss for styling 2021-04-13 17:09:23 -04:00
Christian Muehlhaeuser
9e324205c2 Bump bubbletea, termenv, reflow, go-runewidth, and go-colorful deps 2021-04-03 01:56:59 +02:00
Christian Muehlhaeuser
b2f42066a2 Drop naked returns in textinput model
This actually uncovered a few ineffective assignments, particularly
in deleteWordRight.
2021-03-11 20:09:32 -05:00
Christian Muehlhaeuser
a0d7cb77a1 Fix godocs for various types and functions 2021-03-12 02:06:30 +01:00
Christian Muehlhaeuser
046b9ca129 Enable golint as linter 2021-03-12 02:00:32 +01:00
Christian Muehlhaeuser
da9a4049de Mark innocuous numbers as nolint 2021-03-12 01:47:40 +01:00
Christian Muehlhaeuser
490a599c05 Use a const for the mouse wheel delta in viewport 2021-03-12 01:33:51 +01:00
Kiyon
f719cc8cb1 Reset cursor in SetValue when initial pos is 0 2021-03-09 10:21:17 -05:00
Christian Rocha
c8f4855d20 Fix typo in README 2021-01-12 18:19:20 -05:00
Christian Rocha
43d43d14ae A external link to progress example and remove local example 2021-01-12 18:17:42 -05:00
Christian Rocha
de359c53bb README: improve wording + image update for dark backgrounds 2021-01-12 17:53:56 -05:00
Christian Rocha
c7d69b61f0 Add progressbar info and GIF to README 2021-01-12 17:27:14 -05:00
Christian Rocha
11f56f9b6b If using advanced settings, draw empty spinner when appropriate
When model.HideFor or model.MinimumLifetime is present, we won't draw
the spinner in cases where model.Visibe() returns false.
2020-12-11 19:37:37 -05:00
Christian Rocha
3be2d0585b Prevent spinner from responding to extra, unintentional tick messages 2020-12-11 19:37:37 -05:00
Christian Rocha
37a85afcd1 Fix a typo in a comment 2020-12-10 11:36:22 -05:00
Christian Rocha
f9c79eef64 Slightly more subtle default gradient 2020-12-01 17:03:48 -05:00
Christian Rocha
c303de1e85 Replace "ramp" with "gradient" on exposed functions 2020-12-01 17:03:48 -05:00
Christian Rocha
7e5ef42924 Add ability to style percentage readout 2020-12-01 17:03:48 -05:00
Christian Rocha
79ca4d09d1 Fix doc comments and remove magic number 2020-12-01 17:03:48 -05:00
Christian Rocha
f48e53556a Return an error if we could not initialize a new gradient ramp 2020-12-01 17:03:48 -05:00
Christian Rocha
5d6d8cb0fb Add functional argument for setting the width of the progress bar 2020-12-01 17:03:48 -05:00
Christian Rocha
47b8d9c6a8 Doc comments, optimizations, and magic number removal 2020-12-01 17:03:48 -05:00
Christian Rocha
b78277e7ec Add progress bar example 2020-12-01 17:03:48 -05:00
Christian Rocha
6a768905a6 Add initialization options for scaled ramps 2020-12-01 17:03:48 -05:00
Christian Rocha
0f06d78b92 Support for solid (non-gradient) fills too 2020-12-01 17:03:48 -05:00
Christian Rocha
1e16eca939 Set ramp in NewModel via functional argument 2020-12-01 17:03:48 -05:00
Christian Rocha
d81e5713d4 Cache termenv color profile lookup 2020-12-01 17:03:48 -05:00
Christian Rocha
b10dbcb4dd Add option to show percent 2020-12-01 17:03:48 -05:00
Christian Rocha
a2e0a2e72e Remove initial width argument; add default width 2020-12-01 17:03:48 -05:00
Christian Rocha
3dea7d036e Reduce progress component to (more or less) a pure view function
We really only need the View function in this case since the progress
value will come from the program that implements this.
2020-12-01 17:03:48 -05:00
Richard Cooper
68e87e08c5 gofmt-ed last commit 2020-12-01 17:03:48 -05:00
Richard Cooper
65a17d039e length field changed to width 2020-12-01 17:03:48 -05:00
ololosha228
38d59517fb Added progress bar 2020-12-01 17:03:48 -05:00
Christian Rocha
06109f45ce Add Bubbles title treatment to README 2020-11-19 19:11:24 -05:00
16 changed files with 2820 additions and 242 deletions

View File

@@ -10,8 +10,6 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
with:
# Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
version: v1.30
# Optional: golangci-lint command line arguments.
args: --issues-exit-code=0
# Optional: working directory, useful for monorepos

View File

@@ -2,6 +2,13 @@ run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
@@ -14,11 +21,12 @@ linters:
- godot
- godox
- goimports
- gomnd
- goprintffuncname
- gosec
- ifshort
- misspell
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- unconvert

119
README.md
View File

@@ -1,17 +1,22 @@
Bubbles
=======
<p>
<img src="https://stuff.charm.sh/bubbles/bubbles-github.png" width="233" alt="The Bubbles Logo">
</p>
[![Latest Release](https://img.shields.io/github/release/charmbracelet/bubbles.svg)](https://github.com/charmbracelet/bubbles/releases)
[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/bubbles)
[![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions)
[![Go ReportCard](http://goreportcard.com/badge/charmbracelet/bubbles)](http://goreportcard.com/report/charmbracelet/bubbles)
[![Go ReportCard](https://goreportcard.com/badge/charmbracelet/bubbles)](https://goreportcard.com/report/charmbracelet/bubbles)
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications.
These components are used in production in [Glow][glow] and [Charm][charm].
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea)
applications. These components are used in production in [Glow][glow],
[Charm][charm] and [many other applications][otherstuff].
[glow]: https://github.com/charmbracelet/glow
[charm]: https://github.com/charmbracelet/charm
[otherstuff]: https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild
## Spinner
@@ -21,7 +26,8 @@ These components are used in production in [Glow][glow] and [Charm][charm].
A spinner, useful for indicating that some kind an operation is happening.
There are a couple default ones, but you can also pass your own ”frames.”
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go)
* [Example code, basic spinner](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go)
* [Example code, various spinners](https://github.com/charmbracelet/tea/tree/master/examples/spinners/main.go)
## Text Input
@@ -36,6 +42,21 @@ the common, and many customization options.
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go)
## Progress
<img src="https://stuff.charm.sh/bubbles-examples/progress.gif" width="800" alt="Progressbar Example">
A simple, customizable progress meter, with optional animation via
[Harmonica][harmonica]. Supports solid and gradient fills. The empty and filled
runes can be set to whatever you'd like. The percentage readout is customizable
and can also be omitted entirely.
* [Animated example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-animated/main.go)
* [Static example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-static/main.go)
[harmonica]: https://github.com/charmbracelet/harmonica
## Paginator
<img src="https://stuff.charm.sh/bubbles-examples/pagination.gif" width="200" alt="Paginator Example">
@@ -45,8 +66,7 @@ Supports "dot-style" pagination (similar to what you might see on iOS) and
numeric page numbering, but you could also just use this component for the
logic and visualize pagination however you like.
This component is used in [Glow][glow] to browse documents and [Charm][charm] to
browse SSH keys.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go)
## Viewport
@@ -55,7 +75,7 @@ browse SSH keys.
A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alterate screen buffer.
for applications which make use of the alternate screen buffer.
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go)
@@ -65,6 +85,84 @@ indenting and text wrapping.
[reflow]: https://github.com/muesli/reflow
## List
<img src="https://stuff.charm.sh/bubbles-examples/list.gif" width="600" alt="List Example">
A customizable, batteries-included component for browsing a set of items.
Features pagination, fuzzy filtering, auto-generated help, an activity spinner,
and status messages, all of which can be enabled and disabled as needed.
Extrapolated from [Glow][glow].
* [Example code, default list](https://github.com/charmbracelet/tea/tree/master/examples/list-default/main.go)
* [Example code, simple list](https://github.com/charmbracelet/tea/tree/master/examples/list-simple/main.go)
* [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go)
## Help
<img src="https://stuff.charm.sh/bubbles-examples/help.gif" width="500" alt="Help Example">
A customizable horizontal mini help view that automatically generates itself
from your keybindings. It features single and multi-line modes, which the user
can optionally toggle between. It will truncate gracefully if the terminal is
too wide for the content.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/help/main.go)
## Key
A non-visual component for managing keybindings. Its useful for allowing users
to remap keybindings as well as generating help views corresponding to your
keybindings.
```go
type KeyMap struct {
Up key.Binding
Down key.Binding
}
var DefaultKeyMap = KeyMap{
Up: key.NewBinding(
key.WithKeys("k", "up"), // actual keybindings
key.WithHelp("↑/k", "move up"), // corresponding help text
),
Down: key.NewBinding(
WithKeys("j", "down"),
WithHelp("↓/j", "move down"),
),
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, DefaultKeyMap.Up):
// The user pressed up
case key.Matches(msg, DefaultKeyMap.Down):
// The user pressed down
}
}
return m, nil
}
```
## Additional Bubbles
* [promptkit](https://github.com/erikgeiser/promptkit): A collection of common
prompts for cases like selection, text input, and confirmation. Each prompt
comes with sensible defaults, remappable keybindings, any many customization
options.
* [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose
bubbles. Inputs with validation, menu selection, a modified progressbar, and
so on.
If youve built a Bubble you think should be listed here,
[let us know](mailto:vt100@charm.sh).
## License
[MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE)
@@ -74,7 +172,6 @@ indenting and text wrapping.
Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="the Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
Charm热爱开源! / Charm loves open source!
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source

14
go.mod
View File

@@ -4,9 +4,13 @@ go 1.13
require (
github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbletea v0.12.2
github.com/mattn/go-runewidth v0.0.9
github.com/muesli/termenv v0.7.4
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect
github.com/charmbracelet/bubbletea v0.14.1
github.com/charmbracelet/harmonica v0.1.0
github.com/charmbracelet/lipgloss v0.3.0
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/sahilm/fuzzy v0.1.0
)

61
go.sum
View File

@@ -1,35 +1,44 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.12.2 h1:y9Yo2Pv8tcm3mAJsWONGsmHhzrbNxJVxpVtemikxE9A=
github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/charmbracelet/bubbletea v0.14.1 h1:pD/bM5LBEH/nDo7nKcgNUgi4uRHQhpWTIHZbG5vuSlc=
github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE=
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.3.0 h1:5MysOD6sHr4RP4jkZNWGVIul5GKoOsP12NgbgXPvAlA=
github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

224
help/help.go Normal file
View File

@@ -0,0 +1,224 @@
package help
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// KeyMap is a map of keybindings used to generate help. Since it's an
// interface it can be any type, though struct or a map[string][]key.Binding
// are likely candidates.
//
// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
// rendered in the help view, so in theory generated help should self-manage.
type KeyMap interface {
// ShortHelp returns a slice of bindings to be displayed in the short
// version of the help. The help bubble will render help in the order in
// which the help items are returned here.
ShortHelp() []key.Binding
// MoreHelp returns an extended group of help items, grouped by columns.
// The help bubble will render the help in the order in which the help
// items are returned here.
FullHelp() [][]key.Binding
}
// Styles is a set of available style definitions for the Help bubble.
type Styles struct {
Ellipsis lipgloss.Style
// Styling for the short help
ShortKey lipgloss.Style
ShortDesc lipgloss.Style
ShortSeparator lipgloss.Style
// Styling for the full help
FullKey lipgloss.Style
FullDesc lipgloss.Style
FullSeparator lipgloss.Style
}
// Model contains the state of the help view.
type Model struct {
Width int
ShowAll bool // if true, render the "full" help menu
ShortSeparator string
FullSeparator string
// The symbol we use in the short help when help items have been truncated
// due to width. Periods of ellipsis by default.
Ellipsis string
Styles Styles
}
// NewModel creates a new help view with some useful defaults.
func NewModel() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
Dark: "#626262",
})
descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B2B2B2",
Dark: "#4A4A4A",
})
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#DDDADA",
Dark: "#3C3C3C",
})
return Model{
ShortSeparator: " • ",
FullSeparator: " ",
Ellipsis: "…",
Styles: Styles{
ShortKey: keyStyle,
ShortDesc: descStyle,
ShortSeparator: sepStyle,
Ellipsis: sepStyle.Copy(),
FullKey: keyStyle.Copy(),
FullDesc: descStyle.Copy(),
FullSeparator: sepStyle.Copy(),
},
}
}
// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// View renders the help view's current state.
func (m Model) View(k KeyMap) string {
if m.ShowAll {
return m.FullHelpView(k.FullHelp())
}
return m.ShortHelpView(k.ShortHelp())
}
// ShortHelpView renders a single line help view from a slice of keybindings.
// If the line is longer than the maximum width it will be gracefully
// truncated, showing only as many help items as possible.
func (m Model) ShortHelpView(bindings []key.Binding) string {
if len(bindings) == 0 {
return ""
}
var b strings.Builder
var totalWidth int
var separator = m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
for i, kb := range bindings {
if !kb.Enabled() {
continue
}
var sep string
if totalWidth > 0 && i < len(bindings) {
sep = separator
}
str := sep +
m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
w := lipgloss.Width(str)
// If adding this help item would go over the available width, stop
// drawing.
if m.Width > 0 && totalWidth+w > m.Width {
// Although if there's room for an ellipsis, print that.
tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis)
tailWidth := lipgloss.Width(tail)
if totalWidth+tailWidth < m.Width {
b.WriteString(tail)
}
break
}
totalWidth += w
b.WriteString(str)
}
return b.String()
}
// FullHelpView renders help columns from a slice of key binding slices. Each
// top level slice entry renders into a column.
func (m Model) FullHelpView(groups [][]key.Binding) string {
if len(groups) == 0 {
return ""
}
var (
out []string
totalWidth int
sep = m.Styles.FullSeparator.Render(m.FullSeparator)
sepWidth = lipgloss.Width(sep)
)
// Iterate over groups to build columns
for i, group := range groups {
if group == nil || !shouldRenderColumn(group) {
continue
}
var (
keys []string
descriptions []string
)
// Separate keys and descriptions into different slices
for _, kb := range group {
if !kb.Enabled() {
continue
}
keys = append(keys, kb.Help().Key)
descriptions = append(descriptions, kb.Help().Desc)
}
col := lipgloss.JoinHorizontal(lipgloss.Top,
m.Styles.FullKey.Render(strings.Join(keys, "\n")),
m.Styles.FullKey.Render(" "),
m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
)
// Column
totalWidth += lipgloss.Width(col)
if totalWidth > m.Width {
break
}
out = append(out, col)
// Separator
if i < len(group)-1 {
totalWidth += sepWidth
if totalWidth > m.Width {
break
}
}
out = append(out, sep)
}
return lipgloss.JoinHorizontal(lipgloss.Top, out...)
}
func shouldRenderColumn(b []key.Binding) (ok bool) {
for _, v := range b {
if v.Enabled() {
return true
}
}
return false
}

139
key/key.go Normal file
View File

@@ -0,0 +1,139 @@
// Package key provides some types and functions for generating user-definable
// keymappings useful in Bubble Tea components. There are a few different ways
// you can define a keymapping with this package. Here's one example:
//
// type KeyMap struct {
// Up key.Binding
// Down key.Binding
// }
//
// var DefaultKeyMap = KeyMap{
// Up: key.NewBinding(
// key.WithKeys("k", "up"), // actual keybindings
// key.WithHelp("↑/k", "move up"), // corresponding help text
// ),
// Down: key.NewBinding(
// WithKeys("j", "down"),
// WithHelp("↓/j", "move down"),
// ),
// }
//
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, DefaultKeyMap.Up):
// // The user pressed up
// case key.Matches(msg, DefaultKeyMap.Down):
// // The user pressed down
// }
// }
//
// // ...
// }
//
// The help information, which is not used in the example above, can be used
// to render help text for keystrokes in your views.
package key
import (
tea "github.com/charmbracelet/bubbletea"
)
// Binding describes a set of keybindings and, optionally, their associated
// help text.
type Binding struct {
keys []string
help Help
disabled bool
}
// BindingOpt is an initialization option for a keybinding. It's used as an
// argument to NewBinding.
type BindingOpt func(*Binding)
// NewBinding returns a new keybinding from a set of BindingOpt options.
func NewBinding(opts ...BindingOpt) Binding {
b := &Binding{}
for _, opt := range opts {
opt(b)
}
return *b
}
// WithKeys initializes a keybinding with the given keystrokes.
func WithKeys(keys ...string) BindingOpt {
return func(b *Binding) {
b.keys = keys
}
}
// WithHelp initializes a keybinding with the given help text.
func WithHelp(key, desc string) BindingOpt {
return func(b *Binding) {
b.help = Help{Key: key, Desc: desc}
}
}
// WithDisabled initializes a disabled keybinding.
func WithDisabled() BindingOpt {
return func(b *Binding) {
b.disabled = true
}
}
// SetKeys sets the keys for the keybinding.
func (b *Binding) SetKeys(keys ...string) {
b.keys = keys
}
// Keys returns the keys for the keybinding.
func (b Binding) Keys() []string {
return b.keys
}
// SetHelp sets the help text for the keybinding.
func (b *Binding) SetHelp(key, desc string) {
b.help = Help{Key: key, Desc: desc}
}
// Help returns the Help information for the keybinding.
func (b Binding) Help() Help {
return b.help
}
// Enabled returns whether or not the keybinding is enabled. Disabled
// keybindings won't be activated and won't show up in help. Keybindings are
// enabled by default.
func (b Binding) Enabled() bool {
return !b.disabled
}
// SetEnabled enables or disables the keybinding.
func (b *Binding) SetEnabled(v bool) {
b.disabled = !v
}
// Unbind removes the keys and help from this binding, effectively nullifying
// it. This is a step beyond disabling it, since applications can enable
// or disable key bindings based on application state.
func (b *Binding) Unbind() {
b.keys = nil
b.help = Help{}
}
// Help is help information for a given keybinding.
type Help struct {
Key string
Desc string
}
// Matches checks if the given KeyMsg matches a given binding.
func Matches(k tea.KeyMsg, b Binding) bool {
for _, v := range b.keys {
if k.String() == v && b.Enabled() {
return true
}
}
return false
}

205
list/defaultitem.go Normal file
View File

@@ -0,0 +1,205 @@
package list
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/truncate"
)
// DefaultItemStyles defines styling for a default list item.
// See DefaultItemView for when these come into play.
type DefaultItemStyles struct {
// The Normal state.
NormalTitle lipgloss.Style
NormalDesc lipgloss.Style
// The selected item state.
SelectedTitle lipgloss.Style
SelectedDesc lipgloss.Style
// The dimmed state, for when the filter input is initially activated.
DimmedTitle lipgloss.Style
DimmedDesc lipgloss.Style
// Charcters matching the current filter, if any.
FilterMatch lipgloss.Style
}
// NewDefaultItemStyles returns style definitions for a default item. See
// DefaultItemView for when these come into play.
func NewDefaultItemStyles() (s DefaultItemStyles) {
s.NormalTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}).
Padding(0, 0, 0, 2)
s.NormalDesc = s.NormalTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"})
s.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}).
Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}).
Padding(0, 0, 0, 1)
s.SelectedDesc = s.SelectedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"})
s.DimmedTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 0, 2)
s.DimmedDesc = s.DimmedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"})
s.FilterMatch = lipgloss.NewStyle().Underline(true)
return s
}
// DefaultItem describes an items designed to work with DefaultDelegate.
type DefaultItem interface {
Item
Title() string
Description() string
}
// DefaultDelegate is a standard delegate designed to work in lists. It's
// styled by DefaultItemStyles, which can be customized as you like.
//
// The description line can be hidden by setting Description to false, which
// renders the list as single-line-items. The spacing between items can be set
// with the SetSpacing method.
//
// Setting UpdateFunc is optional. If it's set it will be called when the
// ItemDelegate called, which is called when the list's Update function is
// invoked.
//
// Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to
// include items in the list's default short and full help menus.
type DefaultDelegate struct {
ShowDescription bool
Styles DefaultItemStyles
UpdateFunc func(tea.Msg, *Model) tea.Cmd
ShortHelpFunc func() []key.Binding
FullHelpFunc func() [][]key.Binding
spacing int
}
// NewDefaultDelegate creates a new delegate with default styles.
func NewDefaultDelegate() DefaultDelegate {
return DefaultDelegate{
ShowDescription: true,
Styles: NewDefaultItemStyles(),
spacing: 1,
}
}
// Height returns the delegate's preferred height.
func (d DefaultDelegate) Height() int {
if d.ShowDescription {
return 2 //nolint:gomnd
}
return 1
}
// SetSpacing set the delegate's spacing.
func (d *DefaultDelegate) SetSpacing(i int) {
d.spacing = i
}
// Spacing returns the delegate's spacing.
func (d DefaultDelegate) Spacing() int {
return d.spacing
}
// Update checks whether the delegate's UpdateFunc is set and calls it.
func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
if d.UpdateFunc == nil {
return nil
}
return d.UpdateFunc(msg, m)
}
// Render prints an item.
func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) {
var (
title, desc string
matchedRunes []int
s = &d.Styles
)
if i, ok := item.(DefaultItem); ok {
title = i.Title()
desc = i.Description()
} else {
return
}
// Prevent text from exceeding list width
if m.width > 0 {
textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
title = truncate.StringWithTail(title, textwidth, ellipsis)
desc = truncate.StringWithTail(desc, textwidth, ellipsis)
}
// Conditions
var (
isSelected = index == m.Index()
emptyFilter = m.FilterState() == Filtering && m.FilterValue() == ""
isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied
)
if isFiltered && index < len(m.filteredItems) {
// Get indices of matched characters
matchedRunes = m.MatchesForItem(index)
}
if emptyFilter {
title = s.DimmedTitle.Render(title)
desc = s.DimmedDesc.Render(desc)
} else if isSelected && m.FilterState() != Filtering {
if isFiltered {
// Highlight matches
unmatched := s.SelectedTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.SelectedTitle.Render(title)
desc = s.SelectedDesc.Render(desc)
} else {
if isFiltered {
// Highlight matches
unmatched := s.NormalTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.NormalTitle.Render(title)
desc = s.NormalDesc.Render(desc)
}
if d.ShowDescription {
fmt.Fprintf(w, "%s\n%s", title, desc)
return
}
fmt.Fprintf(w, "%s", title)
}
// ShortHelp returns the delegate's short help.
func (d DefaultDelegate) ShortHelp() []key.Binding {
if d.ShortHelpFunc != nil {
return d.ShortHelpFunc()
}
return nil
}
// FullHelp returns the delegate's full help.
func (d DefaultDelegate) FullHelp() [][]key.Binding {
if d.FullHelpFunc != nil {
return d.FullHelpFunc()
}
return nil
}

97
list/keys.go Normal file
View File

@@ -0,0 +1,97 @@
package list
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
// Keybindings used when browsing the list.
CursorUp key.Binding
CursorDown key.Binding
NextPage key.Binding
PrevPage key.Binding
GoToStart key.Binding
GoToEnd key.Binding
Filter key.Binding
ClearFilter key.Binding
// Keybindings used when setting a filter.
CancelWhileFiltering key.Binding
AcceptWhileFiltering key.Binding
// Help toggle keybindings.
ShowFullHelp key.Binding
CloseFullHelp key.Binding
// The quit keybinding. This won't be caught when filtering.
Quit key.Binding
// The quit-no-matter-what keybinding. This will be caught when filtering.
ForceQuit key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
// Browsing.
CursorUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
CursorDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PrevPage: key.NewBinding(
key.WithKeys("left", "h", "pgup", "b", "u"),
key.WithHelp("←/h/pgup", "prev page"),
),
NextPage: key.NewBinding(
key.WithKeys("right", "l", "pgdown", "f", "d"),
key.WithHelp("→/l/pgdn", "next page"),
),
GoToStart: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GoToEnd: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
Filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
ClearFilter: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
// Filtering.
CancelWhileFiltering: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
AcceptWhileFiltering: key.NewBinding(
key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"),
key.WithHelp("enter", "apply filter"),
),
// Toggle help.
ShowFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "more"),
),
CloseFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "close help"),
),
// Quitting.
Quit: key.NewBinding(
key.WithKeys("q", "esc"),
key.WithHelp("q", "quit"),
),
ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
}
}

1197
list/list.go Normal file

File diff suppressed because it is too large Load Diff

99
list/style.go Normal file
View File

@@ -0,0 +1,99 @@
package list
import (
"github.com/charmbracelet/lipgloss"
)
const (
bullet = "•"
ellipsis = "…"
)
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
TitleBar lipgloss.Style
Title lipgloss.Style
Spinner lipgloss.Style
FilterPrompt lipgloss.Style
FilterCursor lipgloss.Style
// Default styling for matched characters in a filter. This can be
// overridden by delegates.
DefaultFilterCharacterMatch lipgloss.Style
StatusBar lipgloss.Style
StatusEmpty lipgloss.Style
StatusBarActiveFilter lipgloss.Style
StatusBarFilterCount lipgloss.Style
NoItems lipgloss.Style
PaginationStyle lipgloss.Style
HelpStyle lipgloss.Style
// Styled characters.
ActivePaginationDot lipgloss.Style
InactivePaginationDot lipgloss.Style
ArabicPagination lipgloss.Style
DividerDot lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this list
// component.
func DefaultStyles() (s Styles) {
verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
s.Title = lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("230")).
Padding(0, 1)
s.Spinner = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
s.FilterPrompt = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"})
s.FilterCursor = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"})
s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true)
s.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 1, 2)
s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor)
s.StatusBarActiveFilter = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor)
s.NoItems = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor)
s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd
s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2)
s.ActivePaginationDot = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}).
SetString(bullet)
s.InactivePaginationDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(bullet)
s.DividerDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(" " + bullet + " ")
return s
}

View File

@@ -1,4 +1,4 @@
// package paginator provides a Bubble Tea package for calulating pagination
// Package paginator provides a Bubble Tea package for calulating pagination
// and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status.
@@ -35,7 +35,7 @@ type Model struct {
UseJKKeys bool
}
// SetTotalPages is a helper function for calculatng the total number of pages
// SetTotalPages is a helper function for calculating the total number of pages
// from a given number of items. It's use is optional since this pager can be
// used for other things beyond navigating sets. Note that it both returns the
// number of total pages and alters the model.
@@ -91,7 +91,7 @@ func (m *Model) NextPage() {
}
}
// LastPage returns whether or not we're on the last page.
// OnLastPage returns whether or not we're on the last page.
func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1
}

342
progress/progress.go Normal file
View File

@@ -0,0 +1,342 @@
package progress
import (
"fmt"
"math"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/harmonica"
"github.com/charmbracelet/lipgloss"
"github.com/lucasb-eyer/go-colorful"
"github.com/muesli/reflow/ansi"
"github.com/muesli/termenv"
)
// Internal ID management. Used during animating to assure that frame messages
// can only be received by progress components that sent them.
var (
lastID int
idMtx sync.Mutex
)
// Return the next ID we should use on the model.
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
const (
fps = 60
defaultWidth = 40
defaultFrequency = 18.0
defaultDamping = 1.0
)
var color func(string) termenv.Color = termenv.ColorProfile().Color
// Option is used to set options in NewModel. For example:
//
// progress := NewModel(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
//
type Option func(*Model)
// WithDefaultGradient sets a gradient fill with default colors.
func WithDefaultGradient() Option {
return WithGradient("#5A56E0", "#EE6FF8")
}
// WithGradient sets a gradient fill blending between two colors.
func WithGradient(colorA, colorB string) Option {
return func(m *Model) {
m.setRamp(colorA, colorB, false)
}
}
// WithDefaultScaledGradient sets a gradient with default colors, and scales the
// gradient to fit the filled portion of the ramp.
func WithDefaultScaledGradient() Option {
return WithScaledGradient("#5A56E0", "#EE6FF8")
}
// WithScaledGradient scales the gradient to fit the width of the filled portion of
// the progress bar.
func WithScaledGradient(colorA, colorB string) Option {
return func(m *Model) {
m.setRamp(colorA, colorB, true)
}
}
// WithSolidFill sets the progress to use a solid fill with the given color.
func WithSolidFill(color string) Option {
return func(m *Model) {
m.FullColor = color
m.useRamp = false
}
}
// WithoutPercentage hides the numeric percentage.
func WithoutPercentage() Option {
return func(m *Model) {
m.ShowPercentage = false
}
}
// WithWidth sets the initial width of the progress bar. Note that you can also
// set the width via the Width property, which can come in handy if you're
// waiting for a tea.WindowSizeMsg.
func WithWidth(w int) Option {
return func(m *Model) {
m.Width = w
}
}
func WithSpringOptions(frequency, damping float64) Option {
return func(m *Model) {
m.SetSpringOptions(frequency, damping)
m.springCustomized = true
}
}
// FrameMsg indicates that an animation step should occur.
type FrameMsg struct {
id int
tag int
}
// Model stores values we'll use when rendering the progress bar.
type Model struct {
// An identifier to keep us from receiving messages intended for other
// progress bars.
id int
// An identifier to keep us from receiving frame messages too quickly.
tag int
// Total width of the progress bar, including percentage, if set.
Width int
// "Filled" sections of the progress bar.
Full rune
FullColor string
// "Empty" sections of progress bar.
Empty rune
EmptyColor string
// Settings for rendering the numeric percentage.
ShowPercentage bool
PercentFormat string // a fmt string for a float
PercentageStyle lipgloss.Style
// Members for animated transitions.
spring harmonica.Spring
springCustomized bool
percent float64
targetPercent float64
velocity float64
// Gradient settings
useRamp bool
rampColorA colorful.Color
rampColorB colorful.Color
// When true, we scale the gradient to fit the width of the filled section
// of the progress bar. When false, the width of the gradient will be set
// to the full width of the progress bar.
scaleRamp bool
}
// NewModel returns a model with default values.
func NewModel(opts ...Option) Model {
m := Model{
id: nextID(),
Width: defaultWidth,
Full: '█',
FullColor: "#7571F9",
Empty: '░',
EmptyColor: "#606060",
ShowPercentage: true,
PercentFormat: " %3.0f%%",
}
if !m.springCustomized {
m.SetSpringOptions(defaultFrequency, defaultDamping)
}
for _, opt := range opts {
opt(&m)
}
return m
}
// Init exists satisfy the tea.Model interface.
func (m Model) Init() tea.Cmd {
return nil
}
// Update is used to animation the progress bar during transitions. Use
// SetPercent to create the command you'll need to trigger the animation.
//
// If you're rendering with ViewAs you won't need this.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case FrameMsg:
if msg.id != m.id || msg.tag != m.tag {
return m, nil
}
// If we've more or less reached equilibrium, stop updating.
dist := math.Abs(m.percent - m.targetPercent)
if dist < 0.001 && m.velocity < 0.01 {
return m, nil
}
m.percent, m.velocity = m.spring.Update(m.percent, m.velocity, m.targetPercent)
return m, m.nextFrame()
default:
return m, nil
}
}
// SetSpringOptions sets the frequency and damping for the current spring.
// Frequency corresponds to speed, and damping to bounciness. For details see:
// https://github.com/charmbracelet/harmonica.
func (m *Model) SetSpringOptions(frequency, damping float64) {
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
}
// Percent returns the current percentage state of the model. This is only
// relevant when you're animating the progress bar.
//
// If you're rendering with ViewAs you won't need this.
func (m Model) Percent() float64 {
return m.targetPercent
}
// SetPercent sets the percentage state of the model as well as a command
// necessary for animating the progress bar to this new percentage.
//
// If you're rendering with ViewAs you won't need this.
func (m *Model) SetPercent(p float64) tea.Cmd {
m.targetPercent = math.Max(0, math.Min(1, p))
m.tag++
return m.nextFrame()
}
// IncrPercent increments the percentage by a given amount, returning a command
// necessary to animate the progress bar to the new percentage.
//
// If you're rendering with ViewAs you won't need this.
func (m *Model) IncrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() + v)
}
// DecrPercent decrements the percentage by a given amount, returning a command
// necessary to animate the progress bar to the new percentage.
//
// If you're rendering with ViewAs you won't need this.
func (m *Model) DecrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() - v)
}
// View renders the an animated progress bar in its current state. To render
// a static progress bar based on your own calculations use ViewAs instead.
func (m Model) View() string {
return m.ViewAs(m.percent)
}
// ViewAs renders the progress bar with a given percentage.
func (m Model) ViewAs(percent float64) string {
b := strings.Builder{}
percentView := m.percentageView(percent)
m.barView(&b, percent, ansi.PrintableRuneWidth(percentView))
b.WriteString(percentView)
return b.String()
}
func (m *Model) nextFrame() tea.Cmd {
return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg {
return FrameMsg{id: m.id, tag: m.tag}
})
}
func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
var (
tw = max(0, m.Width-textWidth) // total width
fw = int(math.Round((float64(tw) * percent))) // filled width
p float64
)
fw = max(0, min(tw, fw))
if m.useRamp {
// Gradient fill
for i := 0; i < fw; i++ {
if m.scaleRamp {
p = float64(i) / float64(fw)
} else {
p = float64(i) / float64(tw)
}
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
b.WriteString(termenv.
String(string(m.Full)).
Foreground(color(c)).
String(),
)
}
} else {
// Solid fill
s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String()
b.WriteString(strings.Repeat(s, fw))
}
// Empty fill
e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String()
n := max(0, tw-fw)
b.WriteString(strings.Repeat(e, n))
}
func (m Model) percentageView(percent float64) string {
if !m.ShowPercentage {
return ""
}
percent = math.Max(0, math.Min(1, percent))
percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd
percentage = m.PercentageStyle.Inline(true).Render(percentage)
return percentage
}
func (m *Model) setRamp(colorA, colorB string, scaled bool) {
// In the event of an error colors here will default to black. For
// usability's sake, and because such an error is only cosmetic, we're
// ignoring the error for sake of usability.
a, _ := colorful.Hex(colorA)
b, _ := colorful.Hex(colorB)
m.useRamp = true
m.scaleRamp = scaled
m.rampColorA = a
m.rampColorB = b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -1,60 +1,58 @@
package spinner
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/muesli/termenv"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi"
)
const defaultFPS = time.Second / 10
// Spinner is a set of frames used in animating the spinner.
type Spinner struct {
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var (
// Some spinners to choose from. You could also make your own.
Line = Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10,
FPS: time.Second / 10, //nolint:gomnd
}
Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10,
FPS: time.Second / 10, //nolint:gomnd
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12,
FPS: time.Second / 12, //nolint:gomnd
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10,
FPS: time.Second / 10, //nolint:gomnd
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8,
FPS: time.Second / 8, //nolint:gomnd
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7,
FPS: time.Second / 7, //nolint:gomnd
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4,
FPS: time.Second / 4, //nolint:gomnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8,
FPS: time.Second / 8, //nolint:gomnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3,
FPS: time.Second / 3, //nolint:gomnd
}
color = termenv.ColorProfile().Color
)
// Model contains the state for the spinner. Use NewModel to create new models
@@ -64,17 +62,12 @@ type Model struct {
// Spinner settings to use. See type Spinner.
Spinner Spinner
// ForegroundColor sets the background color of the spinner. It can be
// a hex code or one of the 256 ANSI colors. If the terminal emulator can't
// support the color specified it will automatically degrade (per
// github.com/muesli/termenv).
ForegroundColor string
// BackgroundColor sets the background color of the spinner. It can be
// a hex code or one of the 256 ANSI colors. If the terminal emulator can't
// support the color specified it will automatically degrade (per
// github.com/muesli/termenv).
BackgroundColor string
// Style sets the styling for the spinner. Most of the time you'll just
// want foreground and background coloring, and potentially some padding.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
// MinimumLifetime is the minimum amount of time the spinner can run. Any
// logic around this can be implemented in view that implements this
@@ -99,17 +92,49 @@ type Model struct {
frame int
startTime time.Time
tag int
}
// Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Start() {
m.startTime = time.Now()
}
// Finish sets the internal timer to a completed state so long as the spinner
// isn't flagged to be showing. If it is showing, finish has no effect. The
// idea here is that you call Finish if your operation has completed and, if
// the spinner isn't showing yet (by virtue of HideFor) then Visible() doesn't
// show the spinner at all.
//
// This is intended to work in conjunction with MinimumLifetime and
// MinimumStartTime, is completely optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Finish() {
if m.hidden() {
m.startTime = time.Time{}
}
}
// advancedMode returns whether or not the user is making use of HideFor and
// MinimumLifetime properties.
func (m Model) advancedMode() bool {
return m.HideFor > 0 && m.MinimumLifetime > 0
}
// hidden returns whether or not Model.HideFor is in effect.
func (m Model) hidden() bool {
if m.startTime.IsZero() {
@@ -121,12 +146,10 @@ func (m Model) hidden() bool {
return m.startTime.Add(m.HideFor).After(time.Now())
}
// finished returns whether Model.MinimumLifetimeReached has been met.
// finished returns whether the minimum lifetime of this spinner has been
// exceeded.
func (m Model) finished() bool {
if m.startTime.IsZero() {
return true
}
if m.MinimumLifetime == 0 {
if m.startTime.IsZero() || m.MinimumLifetime == 0 {
return true
}
return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now())
@@ -134,13 +157,13 @@ func (m Model) finished() bool {
// Visible returns whether or not the view should be rendered. Works in
// conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should
// use this message directly to determine whether or not to render this view in
// use this method directly to determine whether or not to render this view in
// the parent view and whether to continue sending spin messaging in the
// parent update function.
//
// Also note that using this function is optional and generally considered for
// advanced use only. Most of the time your application logic will determine
// whether or not this view should be used.
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
@@ -156,19 +179,29 @@ func NewModel() Model {
// TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct {
Time time.Time
tag int
}
// Update is the Tea update function. This will advance the spinner one frame
// every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg.(type) {
switch msg := msg.(type) {
case TickMsg:
// If a tag is set, and it's not the one we expect, reject the message.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
if msg.tag > 0 && msg.tag != m.tag {
return m, nil
}
m.frame++
if m.frame >= len(m.Spinner.Frames) {
m.frame = 0
}
return m, m.tick()
m.tag++
return m, m.tick(m.tag)
default:
return m, nil
}
@@ -182,26 +215,27 @@ func (m Model) View() string {
frame := m.Spinner.Frames[m.frame]
if m.ForegroundColor != "" || m.BackgroundColor != "" {
return termenv.
String(frame).
Foreground(color(m.ForegroundColor)).
Background(color(m.BackgroundColor)).
String()
// If we're using the fine-grained hide/show spinner rules and those rules
// deem that the spinner should be hidden, draw an empty space in place of
// the spinner.
if m.advancedMode() && !m.Visible() {
frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame))
}
return frame
return m.Style.Render(frame)
}
// Tick is the command used to advance the spinner one frame.
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}
func (m Model) tick() tea.Cmd {
func (m Model) tick(tag int) tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{
Time: t,
tag: tag,
}
})
}

View File

@@ -3,25 +3,47 @@ package textinput
import (
"context"
"strings"
"sync"
"time"
"unicode"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
const defaultBlinkSpeed = time.Millisecond * 530
// color is a helper for returning colors.
var color func(s string) termenv.Color = termenv.ColorProfile().Color
// Internal ID management for text inputs. Necessary for blink integrity when
// multiple text inputs are involved.
var (
lastID int
idMtx sync.Mutex
)
// blinkMsg and blinkCanceled are used to manage cursor blinking.
type blinkMsg struct{}
// Return the next ID we should use on the Model.
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// blinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type blinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// Messages for clipboard events.
// Internal messages for clipboard operations.
type pasteMsg string
type pasteErrMsg struct{ error }
@@ -49,29 +71,46 @@ type blinkCtx struct {
cancel context.CancelFunc
}
type cursorMode int
// CursorMode describes the behavior of the cursor.
type CursorMode int
// Available cursor modes.
const (
cursorBlink = iota
cursorStatic
cursorHide
CursorBlink CursorMode = iota
CursorStatic
CursorHide
)
// String returns a the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c CursorMode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
// General settings
Prompt string
Placeholder string
Cursor string
BlinkSpeed time.Duration
TextColor string
BackgroundColor string
PlaceholderColor string
CursorColor string
EchoMode EchoMode
EchoCharacter rune
// General settings.
Prompt string
Placeholder string
BlinkSpeed time.Duration
EchoMode EchoMode
EchoCharacter rune
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
BackgroundStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CursorStyle lipgloss.Style
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
@@ -82,11 +121,17 @@ type Model struct {
// viewport. If 0 or less this setting is ignored.
Width int
// The ID of this Model as it relates to other textinput Models.
id int
// The ID of the blink message we're expecting to receive.
blinkTag int
// Underlying text value.
value []rune
// Focus indicates whether user input focus should be on this input
// component. When false, don't blink and ignore keyboard input.
// focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
focus bool
// Cursor blink state.
@@ -104,26 +149,24 @@ type Model struct {
blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor
cursorMode cursorMode
cursorMode CursorMode
}
// NewModel creates a new model with default settings.
func NewModel() Model {
return Model{
Prompt: "> ",
Placeholder: "",
BlinkSpeed: defaultBlinkSpeed,
TextColor: "",
PlaceholderColor: "240",
CursorColor: "",
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
id: nextID(),
value: nil,
focus: false,
blink: true,
pos: 0,
cursorMode: cursorBlink,
cursorMode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
@@ -139,8 +182,8 @@ func (m *Model) SetValue(s string) {
} else {
m.value = runes
}
if m.pos > len(m.value) {
m.SetCursor(len(m.value))
if m.pos == 0 || m.pos > len(m.value) {
m.setCursor(len(m.value))
}
m.handleOverflow()
}
@@ -150,30 +193,69 @@ func (m Model) Value() string {
return string(m.value)
}
// SetCursor start moves the cursor to the given position. If the position is
// Cursor returns the cursor position.
func (m Model) Cursor() int {
return m.pos
}
// SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
// Returns whether or nor the cursor timer should be reset.
func (m *Model) SetCursor(pos int) bool {
func (m *Model) SetCursor(pos int) {
m.setCursor(pos)
}
// setCursor moves the cursor to the given position and returns whether or not
// the cursor blink should be reset. If the position is out of bounds the
// cursor will be moved to the start or end accordingly.
func (m *Model) setCursor(pos int) bool {
m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow()
// Show the cursor unless it's been explicitly hidden
m.blink = m.cursorMode == cursorHide
m.blink = m.cursorMode == CursorHide
// Reset cursor blink if necessary
return m.cursorMode == cursorBlink
return m.cursorMode == CursorBlink
}
// CursorStart moves the cursor to the start of the field. Returns whether or
// not the curosr blink should be reset.
func (m *Model) CursorStart() bool {
return m.SetCursor(0)
// CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() {
m.cursorStart()
}
// CursorEnd moves the cursor to the end of the field. Returns whether or not
// the cursor blink should be reset.
func (m *Model) CursorEnd() bool {
return m.SetCursor(len(m.value))
// cursorStart moves the cursor to the start of the input field and returns
// whether or not the curosr blink should be reset.
func (m *Model) cursorStart() bool {
return m.setCursor(0)
}
// CursorEnd moves the cursor to the end of the input field
func (m *Model) CursorEnd() {
m.cursorEnd()
}
// CursorMode returns the model's cursor mode. For available cursor modes, see
// type CursorMode.
func (m Model) CursorMode() CursorMode {
return m.cursorMode
}
// CursorMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
m.cursorMode = mode
m.blink = m.cursorMode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// cursorEnd moves the cursor to the end of the input field and returns whether
// the cursor should blink should reset.
func (m *Model) cursorEnd() bool {
return m.setCursor(len(m.value))
}
// Focused returns the focus state on the model.
@@ -181,13 +263,20 @@ func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model.
func (m *Model) Focus() {
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be hidden.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.blink = m.cursorMode == cursorHide // show the cursor unless we've explicitly hidden it
m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it
if m.cursorMode == CursorBlink && m.focus {
return m.blinkCmd()
}
return nil
}
// Blur removes the focus state on the model.
// Blur removes the focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() {
m.focus = false
m.blink = true
@@ -197,12 +286,12 @@ func (m *Model) Blur() {
// or not the cursor blink should reset.
func (m *Model) Reset() bool {
m.value = nil
return m.SetCursor(0)
return m.setCursor(0)
}
// handle a clipboard paste event, if supported. Returns whether or not the
// cursor blink should be reset.
func (m *Model) handlePaste(v string) (blink bool) {
// cursor blink should reset.
func (m *Model) handlePaste(v string) bool {
paste := []rune(v)
var availSpace int
@@ -212,7 +301,7 @@ func (m *Model) handlePaste(v string) (blink bool) {
// If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 {
return
return false
}
// If there's not enough space to paste the whole thing cut the pasted
@@ -243,7 +332,7 @@ func (m *Model) handlePaste(v string) (blink bool) {
m.value = append(head, tail...)
// Reset blink state if necessary and run overflow checks
return m.SetCursor(m.pos)
return m.setCursor(m.pos)
}
// If a max width is defined, perform some logic to treat the visible area
@@ -291,47 +380,47 @@ func (m *Model) handleOverflow() {
}
}
// colorText colorizes a given string according to the TextColor value of the
// model.
func (m *Model) colorText(s string) string {
return termenv.
String(s).
Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
// deleteBeforeCursor deletes all text before the cursor. Returns whether or
// not the cursor blink should be reset.
func (m *Model) deleteBeforeCursor() bool {
m.value = m.value[m.pos:]
m.offset = 0
return m.setCursor(0)
}
// colorPlaceholder colorizes a given string according to the TextColor value
// of the model.
func (m *Model) colorPlaceholder(s string) string {
return termenv.
String(s).
Foreground(color(m.PlaceholderColor)).
Background(color(m.BackgroundColor)).
String()
// deleteAfterCursor deletes all text after the cursor. Returns whether or not
// the cursor blink should be reset. If input is masked delete everything after
// the cursor so as not to reveal word breaks in the masked input.
func (m *Model) deleteAfterCursor() bool {
m.value = m.value[:m.pos]
return m.setCursor(len(m.value))
}
// deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset.
func (m *Model) deleteWordLeft() (blink bool) {
func (m *Model) deleteWordLeft() bool {
if m.pos == 0 || len(m.value) == 0 {
return
return false
}
if m.EchoMode != EchoNormal {
return m.deleteBeforeCursor()
}
i := m.pos
blink = m.SetCursor(m.pos - 1)
blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace before cursor
blink = m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
blink = m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
}
break
}
@@ -343,26 +432,31 @@ func (m *Model) deleteWordLeft() (blink bool) {
m.value = append(m.value[:m.pos], m.value[i:]...)
}
return
return blink
}
// deleteWordRight deletes the word right to the cursor. Returns whether or not
// the cursor blink should be reset.
func (m *Model) deleteWordRight() (blink bool) {
// the cursor blink should be reset. If input is masked delete everything after
// the cursor so as not to reveal word breaks in the masked input.
func (m *Model) deleteWordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
return false
}
if m.EchoMode != EchoNormal {
return m.deleteAfterCursor()
}
i := m.pos
blink = m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
blink = m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
} else {
break
}
@@ -373,23 +467,27 @@ func (m *Model) deleteWordRight() (blink bool) {
} else {
m.value = append(m.value[:i], m.value[m.pos:]...)
}
blink = m.SetCursor(i)
return
return m.setCursor(i)
}
// wordLeft moves the cursor one word to the left. Returns whether or not the
// cursor blink should be reset.
func (m *Model) wordLeft() (blink bool) {
// cursor blink should be reset. If input is masked, move input to the start
// so as not to reveal word breaks in the masked input.
func (m *Model) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 {
return
return false
}
i := m.pos - 1
if m.EchoMode != EchoNormal {
return m.cursorStart()
}
blink := false
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
i--
} else {
break
@@ -398,28 +496,33 @@ func (m *Model) wordLeft() (blink bool) {
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
i--
} else {
break
}
}
return
return blink
}
// wordRight moves the cursor one word to the right. Returns whether or not the
// cursor blink should be reset.
func (m *Model) wordRight() (blink bool) {
// cursor blink should be reset. If the input is masked, move input to the end
// so as not to reveal word breaks in the masked input.
func (m *Model) wordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
return false
}
i := m.pos
if m.EchoMode != EchoNormal {
return m.cursorEnd()
}
blink := false
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
i++
} else {
break
@@ -428,14 +531,14 @@ func (m *Model) wordRight() (blink bool) {
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
i++
} else {
break
}
}
return
return blink
}
func (m Model) echoTransform(v string) string {
@@ -469,7 +572,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
resetBlink = m.SetCursor(m.pos - 1)
resetBlink = m.setCursor(m.pos - 1)
}
}
}
@@ -479,33 +582,30 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
break
}
if m.pos > 0 { // left arrow, ^F, back one character
resetBlink = m.SetCursor(m.pos - 1)
resetBlink = m.setCursor(m.pos - 1)
}
case tea.KeyRight, tea.KeyCtrlF:
if msg.Alt { // alt+right arrow, forward one word
resetBlink = m.wordRight()
break
}
if m.pos < len(m.value) { // right arrow, ^F, forward one word
resetBlink = m.SetCursor(m.pos + 1)
if m.pos < len(m.value) { // right arrow, ^F, forward one character
resetBlink = m.setCursor(m.pos + 1)
}
case tea.KeyCtrlW: // ^W, delete word left of cursor
resetBlink = m.deleteWordLeft()
case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning
resetBlink = m.CursorStart()
resetBlink = m.cursorStart()
case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor
if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
}
case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end
resetBlink = m.CursorEnd()
resetBlink = m.cursorEnd()
case tea.KeyCtrlK: // ^K, kill text after cursor
m.value = m.value[:m.pos]
resetBlink = m.SetCursor(len(m.value))
resetBlink = m.deleteAfterCursor()
case tea.KeyCtrlU: // ^U, kill text before cursor
m.value = m.value[m.pos:]
resetBlink = m.SetCursor(0)
m.offset = 0
resetBlink = m.deleteBeforeCursor()
case tea.KeyCtrlV: // ^V paste
return m, Paste
case tea.KeyRunes: // input regular characters
@@ -527,13 +627,36 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
resetBlink = m.SetCursor(m.pos + len(msg.Runes))
resetBlink = m.setCursor(m.pos + len(msg.Runes))
}
}
case initialBlinkMsg:
// We accept all initialBlinkMsgs genrated by the Blink command.
if m.cursorMode != CursorBlink || !m.focus {
return m, nil
}
cmd := m.blinkCmd()
return m, cmd
case blinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blinkable?
if m.cursorMode != CursorBlink || !m.focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd
if m.cursorMode == cursorBlink {
if m.cursorMode == CursorBlink {
m.blink = !m.blink
cmd = m.blinkCmd()
}
@@ -565,13 +688,15 @@ func (m Model) View() string {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
v := m.colorText(m.echoTransform(string(value[:pos])))
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) {
v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it
v += m.colorText(m.echoTransform(string(value[pos+1:]))) // text after cursor
v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else {
v += m.cursorView(" ")
}
@@ -579,60 +704,52 @@ func (m Model) View() string {
// If a max width and background color were set fill the empty spaces with
// the background color.
valWidth := rw.StringWidth(string(value))
if m.Width > 0 && len(m.BackgroundColor) > 0 && valWidth <= m.Width {
if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += strings.Repeat(
termenv.String(" ").Background(color(m.BackgroundColor)).String(),
padding,
)
v += styleText(strings.Repeat(" ", padding))
}
return m.Prompt + v
return m.PromptStyle.Render(m.Prompt) + v
}
// placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string {
var (
v string
p = m.Placeholder
v string
p = m.Placeholder
style = m.PlaceholderStyle.Inline(true).Render
)
// Cursor
if m.blink && m.PlaceholderColor != "" {
v += m.cursorView(m.colorPlaceholder(p[:1]))
if m.blink {
v += m.cursorView(style(p[:1]))
} else {
v += m.cursorView(p[:1])
}
// The rest of the placeholder text
v += m.colorPlaceholder(p[1:])
v += style(p[1:])
return m.Prompt + v
return m.PromptStyle.Render(m.Prompt) + v
}
// cursorView styles the cursor.
func (m Model) cursorView(v string) string {
if m.blink {
if m.TextColor != "" || m.BackgroundColor != "" {
return termenv.String(v).
Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
}
return v
return m.TextStyle.Render(v)
}
return termenv.String(v).
Foreground(color(m.CursorColor)).
Background(color(m.BackgroundColor)).
Reverse().
String()
return m.CursorStyle.Inline(true).Reverse(true).Render(v)
}
// blinkCmd is an internal command used to manage cursor blinking.
func (m Model) blinkCmd() tea.Cmd {
func (m *Model) blinkCmd() tea.Cmd {
if m.cursorMode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
@@ -640,11 +757,13 @@ func (m Model) blinkCmd() tea.Cmd {
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return blinkMsg{}
return blinkMsg{id: m.id, tag: m.blinkTag}
}
return blinkCanceled{}
}
@@ -652,7 +771,7 @@ func (m Model) blinkCmd() tea.Cmd {
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return blinkMsg{}
return initialBlinkMsg{}
}
// Paste is a command for pasting from the clipboard into the text input.

View File

@@ -7,10 +7,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
const spacebar = " "
// MODEL
const (
spacebar = " "
mouseWheelDelta = 3
)
// Model is the Bubble Tea model for this viewport element.
type Model struct {
Width int
Height int
@@ -52,7 +54,7 @@ func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height
}
// Scrollpercent returns the amount scrolled as a float between 0 and 1.
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
@@ -69,6 +71,10 @@ func (m Model) ScrollPercent() float64 {
func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n")
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
}
// Return the lines that should currently be visible in the viewport.
@@ -215,7 +221,7 @@ func (m *Model) GotoTop() (lines []string) {
return lines
}
// GotoTop sets the viewport to the bottom position.
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0)
@@ -333,13 +339,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
lines := m.LineUp(3)
lines := m.LineUp(mouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(3)
lines := m.LineDown(mouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}