54 Commits

Author SHA1 Message Date
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
Christian Rocha
9cb8e8d90a Remove spaces after emoji spinners 2020-11-11 15:39:42 -05:00
Christian Rocha
e6219572e5 Add points spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
81feaacf5b Remove bit8 spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
9a25d8b8b9 Add pulse spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
e8e052c64b Add mini dot spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
3d7cd43046 Rework Spinner so that default spinners have FPS built-in 2020-11-11 15:39:42 -05:00
Christian Rocha
9c38e101d2 Remove internal msg in spinner so all tick msgs can be caught externally 2020-11-10 15:44:42 -05:00
9 changed files with 631 additions and 251 deletions

View File

@@ -14,6 +14,7 @@ linters:
- godot - godot
- godox - godox
- goimports - goimports
- golint
- gomnd - gomnd
- goprintffuncname - goprintffuncname
- gosec - gosec

View File

@@ -1,10 +1,14 @@
Bubbles 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) [![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) [![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) [![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. Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications.
@@ -36,6 +40,17 @@ the common, and many customization options.
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go) * [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. 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.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress/main.go)
## Paginator ## Paginator
<img src="https://stuff.charm.sh/bubbles-examples/pagination.gif" width="200" alt="Paginator Example"> <img src="https://stuff.charm.sh/bubbles-examples/pagination.gif" width="200" alt="Paginator Example">
@@ -55,7 +70,7 @@ browse SSH keys.
A viewport for vertically scrolling content. Optionally includes standard A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available 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) * [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go)
@@ -74,7 +89,6 @@ indenting and text wrapping.
Part of [Charm](https://charm.sh). 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> <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!
Charm热爱开源 • Charm loves open source

10
go.mod
View File

@@ -4,9 +4,11 @@ go 1.13
require ( require (
github.com/atotto/clipboard v0.1.2 github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbletea v0.12.2 github.com/charmbracelet/bubbletea v0.13.1
github.com/mattn/go-runewidth v0.0.9 github.com/charmbracelet/lipgloss v0.1.2
github.com/muesli/termenv v0.7.4 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.12
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
github.com/muesli/termenv v0.8.1
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect
) )

30
go.sum
View File

@@ -1,25 +1,32 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNEDqDVLu69nM=
github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg= github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
github.com/charmbracelet/lipgloss v0.1.2 h1:D+LUMg34W7n2pkuMrevKVxT7HXqnoRHm7IoomkX3/ZU=
github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= 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/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/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 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/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= 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.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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-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 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -28,8 +35,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-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-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

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 // and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and // pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status. // rendering pagination status.
@@ -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 { func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1 return m.Page == m.TotalPages-1
} }

192
progress/progress.go Normal file
View File

@@ -0,0 +1,192 @@
package progress
import (
"fmt"
"strings"
"github.com/lucasb-eyer/go-colorful"
"github.com/muesli/reflow/ansi"
"github.com/muesli/termenv"
)
const defaultWidth = 40
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) error
// 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) error {
return 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) error {
return 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) error {
m.FullColor = color
m.useRamp = false
return nil
}
}
// WithoutPercentage hides the numeric percentage.
func WithoutPercentage() Option {
return func(m *Model) error {
m.ShowPercentage = false
return nil
}
}
// 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) error {
m.Width = w
return nil
}
}
// Model stores values we'll use when rendering the progress bar.
type Model struct {
// 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 *termenv.Style
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, error) {
m := &Model{
Width: defaultWidth,
Full: '█',
FullColor: "#7571F9",
Empty: '░',
EmptyColor: "#606060",
ShowPercentage: true,
PercentFormat: " %3.0f%%",
}
for _, opt := range opts {
if err := opt(m); err != nil {
return nil, err
}
}
return m, nil
}
// View renders the progress bar as a given percentage.
func (m Model) View(percent float64) string {
b := strings.Builder{}
if m.ShowPercentage {
percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd
if m.PercentageStyle != nil {
percentage = m.PercentageStyle.Styled(percentage)
}
m.bar(&b, percent, ansi.PrintableRuneWidth(percentage))
b.WriteString(percentage)
} else {
m.bar(&b, percent, 0)
}
return b.String()
}
func (m Model) bar(b *strings.Builder, percent float64, textWidth int) {
var (
tw = m.Width - textWidth // total width
fw = int(float64(tw) * percent) // filled width
p float64
)
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()
b.WriteString(strings.Repeat(e, tw-fw))
}
func (m *Model) setRamp(colorA, colorB string, scaled bool) error {
a, err := colorful.Hex(colorA)
if err != nil {
return err
}
b, err := colorful.Hex(colorB)
if err != nil {
return err
}
m.useRamp = true
m.scaleRamp = scaled
m.rampColorA = a
m.rampColorB = b
return nil
}

View File

@@ -1,67 +1,73 @@
package spinner package spinner
import ( import (
"strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" 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. // Spinner is a set of frames used in animating the spinner.
type Spinner = []string type Spinner struct {
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var ( var (
// Some spinners to choose from. You could also make your own. Line = Spinner{
Line = Spinner{"|", "/", "-", "\\"} Frames: []string{"|", "/", "-", "\\"},
Dot = Spinner{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "} FPS: time.Second / 10, //nolint:gomnd
Globe = Spinner{"🌍 ", "🌎 ", "🌏 "} }
Moon = Spinner{"🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "} Dot = Spinner{
Monkey = Spinner{"🙈 ", "🙈 ", "🙉 ", "🙊 "} Frames: []string{" ", " ", " ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
Jump = Spinner{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"} FPS: time.Second / 10, //nolint:gomnd
Bit8 = Spinner{ }
"", "⠁", "⠂", "⠃", "⠄", "⠅", "⠆", "⠇", "⡀", "⡁", "⡂", "⡃", "⡄", "⡅", "⡆", "⡇", MiniDot = Spinner{
"⠈", "⠉", "⠊", "⠋", "⠌", "", "", "", "", "", "", "", "", "", "", "⡏", Frames: []string{"", "", "", "", "", "", "", "", "", ""},
"⠐", "⠑", "⠒", "⠓", "⠔", "⠕", "⠖", "⠗", "⡐", "⡑", "⡒", "⡓", "⡔", "⡕", "⡖", "⡗", FPS: time.Second / 12, //nolint:gomnd
"⠘", "⠙", "⠚", "⠛", "⠜", "⠝", "⠞", "⠟", "⡘", "⡙", "⡚", "⡛", "⡜", "⡝", "⡞", "⡟", }
"⠠", "⠡", "⠢", "⠣", "⠤", "⠥", "⠦", "⠧", "⡠", "⡡", "⡢", "⡣", "⡤", "⡥", "⡦", "⡧", Jump = Spinner{
"⠨", "⠩", "⠪", "⠫", "", "", "", "", "⡨", "⡩", "⡪", "⡫", "", "", "⡮", "⡯", Frames: []string{"", "", "", "", "", "", ""},
"⠰", "⠱", "⠲", "⠳", "⠴", "⠵", "⠶", "⠷", "⡰", "⡱", "⡲", "⡳", "⡴", "⡵", "⡶", "⡷", FPS: time.Second / 10, //nolint:gomnd
"⠸", "⠹", "⠺", "⠻", "⠼", "⠽", "⠾", "⠿", "⡸", "⡹", "⡺", "⡻", "⡼", "⡽", "⡾", "⡿", }
"⢀", "⢁", "⢂", "⢃", "⢄", "⢅", "⢆", "⢇", "⣀", "⣁", "⣂", "⣃", "⣄", "⣅", "⣆", "⣇", Pulse = Spinner{
"⢈", "⢉", "⢊", "⢋", "⢌", "⢍", "⢎", "⢏", "⣈", "⣉", "⣊", "", "", "", "", "⣏", Frames: []string{"", "", "", ""},
"⢐", "⢑", "⢒", "⢓", "⢔", "⢕", "⢖", "⢗", "⣐", "⣑", "⣒", "⣓", "⣔", "⣕", "⣖", "⣗", FPS: time.Second / 8, //nolint:gomnd
"⢘", "⢙", "⢚", "⢛", "⢜", "⢝", "⢞", "⢟", "⣘", "⣙", "⣚", "⣛", "⣜", "⣝", "⣞", "⣟", }
"⢠", "⢡", "⢢", "⢣", "⢤", "⢥", "⢦", "⢧", "⣠", "⣡", "⣢", "⣣", "⣤", "⣥", "⣦", "⣧", Points = Spinner{
"⢨", "⢩", "⢪", "⢫", "⢬", "⢭", "⢮", "⢯", "⣨", "⣩", "⣪", "⣫", "", "", "⣮", "⣯", Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
"⢰", "⢱", "⢲", "⢳", "⢴", "⢵", "⢶", "⢷", "⣰", "⣱", "⣲", "⣳", "⣴", "⣵", "⣶", "⣷", FPS: time.Second / 7, //nolint:gomnd
"⢸", "⢹", "⢺", "⢻", "⢼", "⢽", "⢾", "⢿", "⣸", "⣹", "⣺", "⣻", "⣼", "⣽", "⣾", "⣿"} }
Globe = Spinner{
color = termenv.ColorProfile().Color Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, //nolint:gomnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, //nolint:gomnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:gomnd
}
) )
// Model contains the state for the spinner. Use NewModel to create new models // Model contains the state for the spinner. Use NewModel to create new models
// rather than using Model as a struct literal. // rather than using Model as a struct literal.
type Model struct { type Model struct {
// Type is the set of frames to use. See Spinner. // Spinner settings to use. See type Spinner.
Frames Spinner Spinner Spinner
// FPS is the speed at which the ticker should tick. // Style sets the styling for the spinner. Most of the time you'll just
FPS time.Duration // want foreground and background coloring, and potentially some padding.
//
// ForegroundColor sets the background color of the spinner. It can be a // For an introduction to styling with Lip Gloss see:
// hex code or one of the 256 ANSI colors. If the terminal emulator can't // https://github.com/charmbracelet/lipgloss
// doesn't support the color specified it will automatically degrade Style lipgloss.Style
// (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
// doesn't support the color specified it will automatically degrade
// (per github.com/muesli/termenv).
BackgroundColor string
// MinimumLifetime is the minimum amount of time the spinner can run. Any // MinimumLifetime is the minimum amount of time the spinner can run. Any
// logic around this can be implemented in view that implements this // logic around this can be implemented in view that implements this
@@ -86,17 +92,49 @@ type Model struct {
frame int frame int
startTime time.Time startTime time.Time
tag int
} }
// Start resets resets the spinner start time. For use with MinimumLifetime and // Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional. // 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 is considered experimental and may not appear in future versions of
// this library. // this library.
func (m *Model) Start() { func (m *Model) Start() {
m.startTime = time.Now() 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. // hidden returns whether or not Model.HideFor is in effect.
func (m Model) hidden() bool { func (m Model) hidden() bool {
if m.startTime.IsZero() { if m.startTime.IsZero() {
@@ -108,12 +146,10 @@ func (m Model) hidden() bool {
return m.startTime.Add(m.HideFor).After(time.Now()) 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 { func (m Model) finished() bool {
if m.startTime.IsZero() { if m.startTime.IsZero() || m.MinimumLifetime == 0 {
return true
}
if m.MinimumLifetime == 0 {
return true return true
} }
return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now()) return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now())
@@ -121,13 +157,13 @@ func (m Model) finished() bool {
// Visible returns whether or not the view should be rendered. Works in // Visible returns whether or not the view should be rendered. Works in
// conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should // 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 // the parent view and whether to continue sending spin messaging in the
// parent update function. // parent update function.
// //
// Also note that using this function is optional and generally considered for // This function is optional and generally considered for advanced use only.
// advanced use only. Most of the time your application logic will determine // Most of the time your application logic will obviate the need for this
// whether or not this view should be used. // method.
// //
// This is considered experimental and may not appear in future versions of // This is considered experimental and may not appear in future versions of
// this library. // this library.
@@ -137,32 +173,35 @@ func (m Model) Visible() bool {
// NewModel returns a model with default values. // NewModel returns a model with default values.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{Spinner: Line}
Frames: Line,
FPS: defaultFPS,
}
} }
// TickMsg indicates that the timer has ticked and we should render a frame. // TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct { type TickMsg struct {
Time time.Time Time time.Time
tag int
} }
type startTick struct{}
// Update is the Tea update function. This will advance the spinner one frame // 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 // every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly. // is setup so as not to call this Update needlessly.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg.(type) { switch msg := msg.(type) {
case startTick:
return m, m.tick()
case TickMsg: 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++ m.frame++
if m.frame >= len(m.Frames) { if m.frame >= len(m.Spinner.Frames) {
m.frame = 0 m.frame = 0
} }
return m, m.tick()
m.tag++
return m, m.tick(m.tag)
default: default:
return m, nil return m, nil
} }
@@ -170,32 +209,33 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// View renders the model's view. // View renders the model's view.
func (m Model) View() string { func (m Model) View() string {
if m.frame >= len(m.Frames) { if m.frame >= len(m.Spinner.Frames) {
return "error" return "(error)"
} }
frame := m.Frames[m.frame] frame := m.Spinner.Frames[m.frame]
if m.ForegroundColor != "" || m.BackgroundColor != "" { // If we're using the fine-grained hide/show spinner rules and those rules
return termenv. // deem that the spinner should be hidden, draw an empty space in place of
String(frame). // the spinner.
Foreground(color(m.ForegroundColor)). if m.advancedMode() && !m.Visible() {
Background(color(m.BackgroundColor)). frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame))
String()
} }
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 { func Tick() tea.Msg {
return startTick{} return TickMsg{Time: time.Now()}
} }
func (m Model) tick() tea.Cmd { func (m Model) tick(tag int) tea.Cmd {
return tea.Tick(m.FPS, func(t time.Time) tea.Msg { return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{ return TickMsg{
Time: t, Time: t,
tag: tag,
} }
}) })
} }

View File

@@ -3,25 +3,47 @@ package textinput
import ( import (
"context" "context"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth" rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
) )
const defaultBlinkSpeed = time.Millisecond * 530 const defaultBlinkSpeed = time.Millisecond * 530
// color is a helper for returning colors. // Internal ID management for text inputs. Necessary for blink integrity when
var color func(s string) termenv.Color = termenv.ColorProfile().Color // multiple text inputs are involved.
var (
lastID int
idMtx sync.Mutex
)
// blinkMsg and blinkCanceled are used to manage cursor blinking. // Return the next ID we should use on the Model.
type blinkMsg struct{} 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{} type blinkCanceled struct{}
// Messages for clipboard events. // Internal messages for clipboard operations.
type pasteMsg string type pasteMsg string
type pasteErrMsg struct{ error } type pasteErrMsg struct{ error }
@@ -49,29 +71,46 @@ type blinkCtx struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
type cursorMode int // CursorMode describes the behavior of the cursor.
type CursorMode int
// Available cursor modes.
const ( const (
cursorBlink = iota CursorBlink CursorMode = iota
cursorStatic CursorStatic
cursorHide 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. // Model is the Bubble Tea model for this text input element.
type Model struct { type Model struct {
Err error Err error
// General settings // General settings.
Prompt string Prompt string
Placeholder string Placeholder string
Cursor string BlinkSpeed time.Duration
BlinkSpeed time.Duration EchoMode EchoMode
TextColor string EchoCharacter rune
BackgroundColor string
PlaceholderColor string // Styles. These will be applied as inline styles.
CursorColor string //
EchoMode EchoMode // For an introduction to styling with Lip Gloss see:
EchoCharacter rune // 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 // CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit. // 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. // viewport. If 0 or less this setting is ignored.
Width int 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. // Underlying text value.
value []rune value []rune
// Focus indicates whether user input focus should be on this input // focus indicates whether user input focus should be on this input
// component. When false, don't blink and ignore keyboard input. // component. When false, ignore keyboard input and hide the cursor.
focus bool focus bool
// Cursor blink state. // Cursor blink state.
@@ -104,26 +149,24 @@ type Model struct {
blinkCtx *blinkCtx blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor // cursorMode determines the behavior of the cursor
cursorMode cursorMode cursorMode CursorMode
} }
// NewModel creates a new model with default settings. // NewModel creates a new model with default settings.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{
Prompt: "> ", Prompt: "> ",
Placeholder: "",
BlinkSpeed: defaultBlinkSpeed, BlinkSpeed: defaultBlinkSpeed,
TextColor: "",
PlaceholderColor: "240",
CursorColor: "",
EchoCharacter: '*', EchoCharacter: '*',
CharLimit: 0, CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
id: nextID(),
value: nil, value: nil,
focus: false, focus: false,
blink: true, blink: true,
pos: 0, pos: 0,
cursorMode: cursorBlink, cursorMode: CursorBlink,
blinkCtx: &blinkCtx{ blinkCtx: &blinkCtx{
ctx: context.Background(), ctx: context.Background(),
@@ -139,8 +182,8 @@ func (m *Model) SetValue(s string) {
} else { } else {
m.value = runes m.value = runes
} }
if m.pos > len(m.value) { if m.pos == 0 || m.pos > len(m.value) {
m.SetCursor(len(m.value)) m.setCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
} }
@@ -150,30 +193,69 @@ func (m Model) Value() string {
return string(m.value) 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. // 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) {
func (m *Model) SetCursor(pos int) bool { 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.pos = clamp(pos, 0, len(m.value))
m.handleOverflow() m.handleOverflow()
// Show the cursor unless it's been explicitly hidden // Show the cursor unless it's been explicitly hidden
m.blink = m.cursorMode == cursorHide m.blink = m.cursorMode == CursorHide
// Reset cursor blink if necessary // 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 // CursorStart moves the cursor to the start of the input field.
// not the curosr blink should be reset. func (m *Model) CursorStart() {
func (m *Model) CursorStart() bool { m.cursorStart()
return m.SetCursor(0)
} }
// CursorEnd moves the cursor to the end of the field. Returns whether or not // cursorStart moves the cursor to the start of the input field and returns
// the cursor blink should be reset. // whether or not the curosr blink should be reset.
func (m *Model) CursorEnd() bool { func (m *Model) cursorStart() bool {
return m.SetCursor(len(m.value)) 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. // Focused returns the focus state on the model.
@@ -181,13 +263,20 @@ func (m Model) Focused() bool {
return m.focus return m.focus
} }
// Focus sets the focus state on the model. // Focus sets the focus state on the model. When the model is in focus it can
func (m *Model) Focus() { // receive keyboard input and the cursor will be hidden.
func (m *Model) Focus() tea.Cmd {
m.focus = true 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() { func (m *Model) Blur() {
m.focus = false m.focus = false
m.blink = true m.blink = true
@@ -197,12 +286,12 @@ func (m *Model) Blur() {
// or not the cursor blink should reset. // or not the cursor blink should reset.
func (m *Model) Reset() bool { func (m *Model) Reset() bool {
m.value = nil m.value = nil
return m.SetCursor(0) return m.setCursor(0)
} }
// handle a clipboard paste event, if supported. Returns whether or not the // handle a clipboard paste event, if supported. Returns whether or not the
// cursor blink should be reset. // cursor blink should reset.
func (m *Model) handlePaste(v string) (blink bool) { func (m *Model) handlePaste(v string) bool {
paste := []rune(v) paste := []rune(v)
var availSpace int var availSpace int
@@ -212,7 +301,7 @@ func (m *Model) handlePaste(v string) (blink bool) {
// If the char limit's been reached cancel // If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 { if m.CharLimit > 0 && availSpace <= 0 {
return return false
} }
// If there's not enough space to paste the whole thing cut the pasted // 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...) m.value = append(head, tail...)
// Reset blink state if necessary and run overflow checks // 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 // 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 // deleteBeforeCursor deletes all text before the cursor. Returns whether or
// model. // not the cursor blink should be reset.
func (m *Model) colorText(s string) string { func (m *Model) deleteBeforeCursor() bool {
return termenv. m.value = m.value[m.pos:]
String(s). m.offset = 0
Foreground(color(m.TextColor)). return m.setCursor(0)
Background(color(m.BackgroundColor)).
String()
} }
// colorPlaceholder colorizes a given string according to the TextColor value // deleteAfterCursor deletes all text after the cursor. Returns whether or not
// of the model. // the cursor blink should be reset. If input is masked delete everything after
func (m *Model) colorPlaceholder(s string) string { // the cursor so as not to reveal word breaks in the masked input.
return termenv. func (m *Model) deleteAfterCursor() bool {
String(s). m.value = m.value[:m.pos]
Foreground(color(m.PlaceholderColor)). return m.setCursor(len(m.value))
Background(color(m.BackgroundColor)).
String()
} }
// deleteWordLeft deletes the word left to the cursor. Returns whether or not // deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset. // 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 { if m.pos == 0 || len(m.value) == 0 {
return return false
}
if m.EchoMode != EchoNormal {
return m.deleteBeforeCursor()
} }
i := m.pos i := m.pos
blink = m.SetCursor(m.pos - 1) blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace before cursor // ignore series of whitespace before cursor
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
} }
for m.pos > 0 { for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
} else { } else {
if m.pos > 0 { if m.pos > 0 {
// keep the previous space // keep the previous space
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
} }
break break
} }
@@ -343,26 +432,31 @@ func (m *Model) deleteWordLeft() (blink bool) {
m.value = append(m.value[:m.pos], m.value[i:]...) 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 // deleteWordRight deletes the word right to the cursor. Returns whether or not
// the cursor blink should be reset. // the cursor blink should be reset. If input is masked delete everything after
func (m *Model) deleteWordRight() (blink bool) { // 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 { if m.pos >= len(m.value) || len(m.value) == 0 {
return return false
}
if m.EchoMode != EchoNormal {
return m.deleteAfterCursor()
} }
i := m.pos i := m.pos
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor // ignore series of whitespace after cursor
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
} }
for m.pos < len(m.value) { for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
} else { } else {
break break
} }
@@ -373,23 +467,27 @@ func (m *Model) deleteWordRight() (blink bool) {
} else { } else {
m.value = append(m.value[:i], m.value[m.pos:]...) 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 // wordLeft moves the cursor one word to the left. Returns whether or not the
// cursor blink should be reset. // cursor blink should be reset. If input is masked, move input to the start
func (m *Model) wordLeft() (blink bool) { // so as not to reveal word breaks in the masked input.
func (m *Model) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { 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 { for i >= 0 {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
i-- i--
} else { } else {
break break
@@ -398,28 +496,33 @@ func (m *Model) wordLeft() (blink bool) {
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
i-- i--
} else { } else {
break break
} }
} }
return return blink
} }
// wordRight moves the cursor one word to the right. Returns whether or not the // wordRight moves the cursor one word to the right. Returns whether or not the
// cursor blink should be reset. // cursor blink should be reset. If the input is masked, move input to the end
func (m *Model) wordRight() (blink bool) { // 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 { 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) { for i < len(m.value) {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
i++ i++
} else { } else {
break break
@@ -428,14 +531,14 @@ func (m *Model) wordRight() (blink bool) {
for i < len(m.value) { for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
i++ i++
} else { } else {
break break
} }
} }
return return blink
} }
func (m Model) echoTransform(v string) string { 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 { if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 { 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 break
} }
if m.pos > 0 { // left arrow, ^F, back one character 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: case tea.KeyRight, tea.KeyCtrlF:
if msg.Alt { // alt+right arrow, forward one word if msg.Alt { // alt+right arrow, forward one word
resetBlink = m.wordRight() resetBlink = m.wordRight()
break break
} }
if m.pos < len(m.value) { // right arrow, ^F, forward one word if m.pos < len(m.value) { // right arrow, ^F, forward one character
resetBlink = m.SetCursor(m.pos + 1) resetBlink = m.setCursor(m.pos + 1)
} }
case tea.KeyCtrlW: // ^W, delete word left of cursor case tea.KeyCtrlW: // ^W, delete word left of cursor
resetBlink = m.deleteWordLeft() resetBlink = m.deleteWordLeft()
case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning 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 case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor
if len(m.value) > 0 && m.pos < len(m.value) { if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
} }
case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end
resetBlink = m.CursorEnd() resetBlink = m.cursorEnd()
case tea.KeyCtrlK: // ^K, kill text after cursor case tea.KeyCtrlK: // ^K, kill text after cursor
m.value = m.value[:m.pos] resetBlink = m.deleteAfterCursor()
resetBlink = m.SetCursor(len(m.value))
case tea.KeyCtrlU: // ^U, kill text before cursor case tea.KeyCtrlU: // ^U, kill text before cursor
m.value = m.value[m.pos:] resetBlink = m.deleteBeforeCursor()
resetBlink = m.SetCursor(0)
m.offset = 0
case tea.KeyCtrlV: // ^V paste case tea.KeyCtrlV: // ^V paste
return m, Paste return m, Paste
case tea.KeyRunes: // input regular characters case tea.KeyRunes: // input regular characters
@@ -527,13 +627,36 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Input a regular character // Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit { if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...) 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: 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 var cmd tea.Cmd
if m.cursorMode == cursorBlink { if m.cursorMode == CursorBlink {
m.blink = !m.blink m.blink = !m.blink
cmd = m.blinkCmd() cmd = m.blinkCmd()
} }
@@ -565,13 +688,15 @@ func (m Model) View() string {
return m.placeholderView() return m.placeholderView()
} }
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight] value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset) 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) { if pos < len(value) {
v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it 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 += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else { } else {
v += m.cursorView(" ") 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 // If a max width and background color were set fill the empty spaces with
// the background color. // the background color.
valWidth := rw.StringWidth(string(value)) 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) padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) { if valWidth+padding <= m.Width && pos < len(value) {
padding++ padding++
} }
v += strings.Repeat( v += styleText(strings.Repeat(" ", padding))
termenv.String(" ").Background(color(m.BackgroundColor)).String(),
padding,
)
} }
return m.Prompt + v return m.PromptStyle.Render(m.Prompt) + v
} }
// placeholderView returns the prompt and placeholder view, if any. // placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string { func (m Model) placeholderView() string {
var ( var (
v string v string
p = m.Placeholder p = m.Placeholder
style = m.PlaceholderStyle.Inline(true).Render
) )
// Cursor // Cursor
if m.blink && m.PlaceholderColor != "" { if m.blink {
v += m.cursorView(m.colorPlaceholder(p[:1])) v += m.cursorView(style(p[:1]))
} else { } else {
v += m.cursorView(p[:1]) v += m.cursorView(p[:1])
} }
// The rest of the placeholder text // 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. // cursorView styles the cursor.
func (m Model) cursorView(v string) string { func (m Model) cursorView(v string) string {
if m.blink { if m.blink {
if m.TextColor != "" || m.BackgroundColor != "" { return m.TextStyle.Render(v)
return termenv.String(v).
Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
}
return v
} }
return termenv.String(v). return m.CursorStyle.Inline(true).Reverse(true).Render(v)
Foreground(color(m.CursorColor)).
Background(color(m.BackgroundColor)).
Reverse().
String()
} }
// blinkCmd is an internal command used to manage cursor blinking. // 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 { if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel() m.blinkCtx.cancel()
} }
@@ -640,11 +757,13 @@ func (m Model) blinkCmd() tea.Cmd {
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg { return func() tea.Msg {
defer cancel() defer cancel()
<-ctx.Done() <-ctx.Done()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return blinkMsg{} return blinkMsg{id: m.id, tag: m.blinkTag}
} }
return blinkCanceled{} return blinkCanceled{}
} }
@@ -652,7 +771,7 @@ func (m Model) blinkCmd() tea.Cmd {
// Blink is a command used to initialize cursor blinking. // Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg { func Blink() tea.Msg {
return blinkMsg{} return initialBlinkMsg{}
} }
// Paste is a command for pasting from the clipboard into the text input. // 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" tea "github.com/charmbracelet/bubbletea"
) )
const spacebar = " " const (
spacebar = " "
// MODEL mouseWheelDelta = 3
)
// Model is the Bubble Tea model for this viewport element.
type Model struct { type Model struct {
Width int Width int
Height int Height int
@@ -52,7 +54,7 @@ func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height 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 { func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) { if m.Height >= len(m.lines) {
return 1.0 return 1.0
@@ -69,6 +71,10 @@ func (m Model) ScrollPercent() float64 {
func (m *Model) SetContent(s string) { func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n") 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. // Return the lines that should currently be visible in the viewport.
@@ -215,7 +221,7 @@ func (m *Model) GotoTop() (lines []string) {
return lines return lines
} }
// GotoTop sets the viewport to the bottom position. // GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) { func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0) 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: case tea.MouseMsg:
switch msg.Type { switch msg.Type {
case tea.MouseWheelUp: case tea.MouseWheelUp:
lines := m.LineUp(3) lines := m.LineUp(mouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
lines := m.LineDown(3) lines := m.LineDown(mouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }