53 Commits

Author SHA1 Message Date
Christian Rocha
6d6149cea8 fix(progress): update progress bar threshold method per changes in d897463 2022-03-31 15:15:59 -04:00
Christian Rocha
00d61decf4 Add minimum percent change needed to trigger an animation in progress 2022-03-31 15:15:59 -04:00
Christian Rocha
430b7b5d36 Remove provisional spinner lifetime stuff
The design was overly complicated, especially for such subtle benefits.
2022-03-31 10:59:39 -04:00
Christian Rocha
00ec90b59f docs(list): fix typo in doc comment 2022-03-30 15:24:24 -04:00
Carlos A Becker
aa0744fd8d docs: godoc
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Carlos A Becker
cf1fe5f9ce fix: type name
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Carlos A Becker
6c18900279 feat: allow custom filter functions
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Christian Rocha
d897463138 chore(progress): Percent() now return progress as presented visually
Prior to this change Percent() returned the target progress to which the
progress bar was animating.
2022-03-30 14:48:46 -04:00
Ayman Bagabas
88562515cf fix(list): rendering empty list
Fixes: 4e18245481 ("Add list bubble")
2022-03-02 17:38:35 -05:00
Austin Schey
e349920524 Add Blink method to textinput to return blink state 2022-03-01 07:35:21 -05:00
Christian Rocha
64b9e0582f docs: add more libs to Additional Bubbles in the README 2022-02-25 14:47:11 -05:00
Christian Rocha
057f7b9a4d Add evertras/bubble-table to 'Additional Bubbles' section in README
Items in that section are now organized alphabetically.
2022-02-17 10:35:07 -05:00
lorenries
1d489252fe fix(list): disable quit keybinding while filtering 2022-02-08 14:42:03 -05:00
treilik
06358c35f9 Add bubblelister and bubbleboxer to "additional bubbles" in the README (#113) 2022-02-06 07:04:52 +01:00
mirko
005233b529 Improve insert item documentation (#115) 2022-02-03 02:58:08 +01:00
Ayman Bagabas
18d25458da fix(list): DisableQuitKeybinding is ignored after updating the list (#108) 2022-01-27 13:09:53 -05:00
Ayman Bagabas
db97ac515d feat: sync bubbles with git.charm.sh 2022-01-24 17:08:23 -05:00
Christian Muehlhaeuser
200f95759b Fix key binding documentation
Fixes #105.
2022-01-24 15:09:43 +01:00
Christian Rocha
7ecce3fb97 Ignore width/height settings in viewport's style settings
The Lip Gloss width and height settings compete with the main
width/height settings and can result in funny rendering and generally
cause confusion.
2022-01-20 13:23:58 -05:00
Christian Rocha
746834a7ce Add safety check in textinput's clamp 2022-01-20 13:23:58 -05:00
Christian Rocha
fd306528f9 Rename var in deleteWordRight in textinput for additional clarity 2022-01-20 13:23:58 -05:00
Christian Rocha
a4dc540f3d Re-add panic guard in deleteWordLeft in textinput, just in case 2022-01-20 13:23:58 -05:00
IllusionMan1212
151d1026dd fix(textinput): use old cursor pos and simplify logic 2022-01-19 18:31:55 -05:00
Christian Rocha
daba232df4 Fix a bug where viewport y-offset could be negative 2022-01-19 12:32:05 -05:00
Christian Rocha
7a6d306889 Fix off-by-one error in viewport's GotoBottom()
This was most likely introduced by the fix for rendering final lines in
0ac5ecdf81.
2022-01-19 12:32:05 -05:00
Christian Rocha
6c015a2aa8 Remove an unused method 2022-01-19 12:31:50 -05:00
Christian Rocha
764fd321f6 Cleanup an old var 2022-01-19 12:31:50 -05:00
Christian Rocha
091c915462 Fix various godoc comments 2022-01-19 12:31:50 -05:00
Christian Rocha
505a16d057 Bump lipgloss and atotto/clipboard to their latest versions 2022-01-14 13:40:18 -05:00
Christian Rocha
465a66e963 Add timer and stopwatch info to README 2022-01-13 11:30:10 -05:00
Jonah
7a728eae31 expose list.FilterMachesMsg (#77) 2022-01-11 12:28:44 -05:00
IllusionMan1212
c426cb580b fix textinput infinite loop and panic
fixes infinite loop when deleting input that only contains whitespace
using deleteWordLeft()
fixes index out of range panic when deleting input that only contains
whitespace using deleteWordRight()
2022-01-11 12:27:57 -05:00
Christian Rocha
48e3f85baf Update keybindings after calling InsertItem on the list model
Specifically, if the list was emtpy prior to inserting an item the
up/down keybindings would be hidden and disabled.
2022-01-11 12:08:33 -05:00
Christian Stewart
5f256bf34f fix(list): update keybindings when setting items
Bug: when calling SetItems when items was previously empty, the keybindings for
up/down do not appear.

Fix: call updateKeybindings in SetItems.

Signed-off-by: Christian Stewart <christian@paral.in>
2022-01-11 11:58:01 -05:00
Christian Stewart
eef9098f37 fix(list): check items slice len
Signed-off-by: Christian Stewart <christian@paral.in>
2022-01-11 11:58:01 -05:00
Christian Rocha
b35f96cd2d Deprecate NewModel() constructors; use New() instead 2022-01-11 11:26:13 -05:00
Christian Rocha
9401ebbb83 Viewport New() is now optional to ease the upgrade process 2022-01-10 21:21:04 -05:00
Christian Rocha
add13c8028 Add a lipgloss style to the viewport for borders, margins, and padding 2022-01-10 21:21:04 -05:00
Christian Rocha
4aed4e0a88 Viewport now has customizable keybindings 2022-01-10 21:21:04 -05:00
Christian Rocha
9c70b6a216 Bump bubbletea dep 2022-01-10 15:01:52 -05:00
Christian Rocha
9d74635ea3 Update footer image in README 2022-01-10 14:57:12 -05:00
Christian Rocha
e01ee1d17e Expose IDs on spinners and spinner tick messages 2022-01-10 14:52:35 -05:00
Christian Rocha
e83c113d06 Add spinner.Model.Tick method, deprecate spinner.Tick method. 2022-01-10 14:52:35 -05:00
Christian Rocha
94b84b6120 Make spinners ignore tick messsages send by other spinners 2022-01-10 14:52:35 -05:00
Christian Rocha
f09987549a Various timer improvements (#83)
* Add IDs to timers
* Add IDs to timer-related messages
* Ignore messages from other timers
* Add `Timeout` property to `TickMsg`
* Add `Start()`, `Stop()`, and `Toggle()` commands to timer
* Add `Timedout()` method to timer model
* Add `Running()` method to timer model
2022-01-10 13:55:54 -05:00
Christian Rocha
7b20f4fe24 Expose stopwatch StartStopMsg and ResetMsg and bind them to IDs 2022-01-10 13:37:34 -05:00
Christian Rocha
86e0c53e88 Add support for multiple stopwatches
In short, stopwatches will now ignore messages sent by other
stopwatches.
2022-01-10 13:37:34 -05:00
Christian Rocha
8d3cfdf380 key.Matches now accepts multiple binding arguments 2022-01-10 12:32:37 -05:00
Anirvan Chatterjee
0f500d5e59 Fixed typo in README ("complimented" → "complemented") 2021-12-09 09:58:11 +01:00
Christian Rocha
0ac5ecdf81 Fix bug where performance rendering could render one line too many 2021-09-17 16:25:25 -04:00
Christian Rocha
8c03905dbe Fix bug where viewport wouldn't render final lines
This previously went unnoticed because we all seemed to have newlines at
the end of our viewport input.

This update introduces the Model.SetYOffset method.
2021-09-17 16:25:25 -04:00
Carlos Alexandro Becker
a7ea1bddbf feat: stopwatch (#68)
* feat: stopwatch

Signed-off-by: Carlos Alexandro Becker <caarlos0@gmail.com>
2021-09-09 15:21:26 -04:00
Carlos Alexandro Becker
7941c49504 feat: simple timer component (#67)
* feat: simple timer component

Signed-off-by: Carlos Alexandro Becker <caarlos0@gmail.com>
2021-09-09 15:17:33 -04:00
15 changed files with 814 additions and 324 deletions

12
.github/workflows/soft-serve.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: soft-serve
on:
push:
branches:
- master
jobs:
soft-serve:
uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
secrets:
ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"

View File

@@ -71,7 +71,7 @@ logic and visualize pagination however you like.
## Viewport ## Viewport
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif" width="600" alt="Viewport Example"> <img src="https://stuff.charm.sh/bubbles-examples/viewport.gif?0" width="600" alt="Viewport Example">
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
@@ -79,7 +79,7 @@ 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)
This component is well complimented with [Reflow][reflow] for ANSI-aware This component is well complemented with [Reflow][reflow] for ANSI-aware
indenting and text wrapping. indenting and text wrapping.
[reflow]: https://github.com/muesli/reflow [reflow]: https://github.com/muesli/reflow
@@ -99,6 +99,26 @@ Extrapolated from [Glow][glow].
* [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go) * [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go)
## Timer
A simple, flexible component for counting down. The update frequency and output
can be customized as you like.
<img src="https://stuff.charm.sh/bubbles-examples/timer.gif" width="400" alt="Timer example">
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/timer/main.go)
## Stopwatch
<img src="https://stuff.charm.sh/bubbles-examples/stopwatch.gif" width="400" alt="Stopwatch example">
A simple, flexible component for counting up. The update frequency and output
can be customized as you see fit.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/stopwatch/main.go)
## Help ## Help
<img src="https://stuff.charm.sh/bubbles-examples/help.gif" width="500" alt="Help Example"> <img src="https://stuff.charm.sh/bubbles-examples/help.gif" width="500" alt="Help Example">
@@ -129,8 +149,8 @@ var DefaultKeyMap = KeyMap{
key.WithHelp("↑/k", "move up"), // corresponding help text key.WithHelp("↑/k", "move up"), // corresponding help text
), ),
Down: key.NewBinding( Down: key.NewBinding(
WithKeys("j", "down"), key.WithKeys("j", "down"),
WithHelp("↓/j", "move down"), key.WithHelp("↓/j", "move down"),
), ),
} }
@@ -151,13 +171,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
## Additional Bubbles ## Additional Bubbles
* [promptkit](https://github.com/erikgeiser/promptkit): A collection of common * [76creates/stickers](https://github.com/76creates/stickers): Responsive
prompts for cases like selection, text input, and confirmation. Each prompt flexbox and table components.
comes with sensible defaults, remappable keybindings, any many customization * [calyptia/go-bubble-table](https://github.com/calyptia/go-bubble-table): An
options. interactive, customizable, scrollable table component.
* [erikgeiser/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.
* [evertras/bubble-table](https://github.com/Evertras/bubble-table): Interactive,
customizable, paginated tables.
* [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose * [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose
bubbles. Inputs with validation, menu selection, a modified progressbar, and bubbles. Inputs with validation, menu selection, a modified progressbar, and
so on. so on.
* [treilik/bubbleboxer](https://github.com/treilik/bubbleboxer): Layout
multiple bubbles side-by-side in a layout-tree.
* [treilik/bubblelister](https://github.com/treilik/bubblelister): An alternate
list that is scrollable without pagination and has the ability to contain
other bubbles as list items.
If youve built a Bubble you think should be listed here, If youve built a Bubble you think should be listed here,
[let us know](mailto:vt100@charm.sh). [let us know](mailto:vt100@charm.sh).
@@ -172,6 +203,8 @@ If youve built a Bubble you think should be listed here,
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-unrounded.jpg" width="400"></a> <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 Charm热爱开源 • Charm loves open source
[charm]: https://charm.sh/

6
go.mod
View File

@@ -3,10 +3,10 @@ module github.com/charmbracelet/bubbles
go 1.13 go 1.13
require ( require (
github.com/atotto/clipboard v0.1.2 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.14.1 github.com/charmbracelet/bubbletea v0.19.3
github.com/charmbracelet/harmonica v0.1.0 github.com/charmbracelet/harmonica v0.1.0
github.com/charmbracelet/lipgloss v0.3.0 github.com/charmbracelet/lipgloss v0.4.0
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13 github.com/mattn/go-runewidth v0.0.13

24
go.sum
View File

@@ -1,28 +1,28 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.14.1 h1:pD/bM5LBEH/nDo7nKcgNUgi4uRHQhpWTIHZbG5vuSlc= github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= 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/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.3.0 h1:5MysOD6sHr4RP4jkZNWGVIul5GKoOsP12NgbgXPvAlA= github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= 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-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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/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 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 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 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -36,9 +36,9 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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 h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@@ -57,8 +57,8 @@ type Model struct {
Styles Styles Styles Styles
} }
// NewModel creates a new help view with some useful defaults. // New creates a new help view with some useful defaults.
func NewModel() Model { func New() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090", Light: "#909090",
Dark: "#626262", Dark: "#626262",
@@ -90,6 +90,11 @@ func NewModel() Model {
} }
} }
// NewModel creates a new help view with some useful defaults.
//
// Deprecated. Use New instead.
var NewModel = New
// Update helps satisfy the Bubble Tea Model interface. It's a no-op. // Update helps satisfy the Bubble Tea Model interface. It's a no-op.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil return m, nil
@@ -160,7 +165,12 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
} }
var ( var (
out []string // Linter note: at this time we don't think it's worth the additional
// code complexity involved in preallocating this slice.
//
//nolint:prealloc
out []string
totalWidth int totalWidth int
sep = m.Styles.FullSeparator.Render(m.FullSeparator) sep = m.Styles.FullSeparator.Render(m.FullSeparator)
sepWidth = lipgloss.Width(sep) sepWidth = lipgloss.Width(sep)

View File

@@ -13,8 +13,8 @@
// key.WithHelp("↑/k", "move up"), // corresponding help text // key.WithHelp("↑/k", "move up"), // corresponding help text
// ), // ),
// Down: key.NewBinding( // Down: key.NewBinding(
// WithKeys("j", "down"), // key.WithKeys("j", "down"),
// WithHelp("↓/j", "move down"), // key.WithHelp("↓/j", "move down"),
// ), // ),
// } // }
// //
@@ -128,11 +128,13 @@ type Help struct {
Desc string Desc string
} }
// Matches checks if the given KeyMsg matches a given binding. // Matches checks if the given KeyMsg matches the given bindings.
func Matches(k tea.KeyMsg, b Binding) bool { func Matches(k tea.KeyMsg, b ...Binding) bool {
for _, v := range b.keys { for _, binding := range b {
if k.String() == v && b.Enabled() { for _, v := range binding.keys {
return true if k.String() == v && binding.Enabled() {
return true
}
} }
} }
return false return false

View File

@@ -67,15 +67,37 @@ func (f filteredItems) items() []Item {
return agg return agg
} }
func (f filteredItems) matches() [][]int { // FilterMatchesMsg contains data about items matched during filtering. The
agg := make([][]int, len(f)) // message should be routed to Update for processing.
for i, v := range f { type FilterMatchesMsg []filteredItem
agg[i] = v.matches
} // FilterFunc takes a term and a list of strings to search through
return agg // (defined by Item#FilterValue).
// It should return a sorted list of ranks.
type FilterFunc func(string, []string) []Rank
// Rank defines a rank for a given item.
type Rank struct {
// The index of the item in the original input.
Index int
// Indices of the actual word that were matched against the filter term.
MatchedIndexes []int
} }
type filterMatchesMsg []filteredItem // DefaultFilter uses the sahilm/fuzzy to filter through the list.
// This is set by default.
func DefaultFilter(term string, targets []string) []Rank {
var ranks fuzzy.Matches = fuzzy.Find(term, targets)
sort.Stable(ranks)
result := make([]Rank, len(ranks))
for i, r := range ranks {
result[i] = Rank{
Index: r.Index,
MatchedIndexes: r.MatchedIndexes,
}
}
return result
}
type statusMessageTimeoutMsg struct{} type statusMessageTimeoutMsg struct{}
@@ -113,6 +135,11 @@ type Model struct {
// Key mappings for navigating the list. // Key mappings for navigating the list.
KeyMap KeyMap KeyMap KeyMap
// Filter is used to filter the list.
Filter FilterFunc
disableQuitKeybindings bool
// Additional key mappings for the short and full help views. This allows // Additional key mappings for the short and full help views. This allows
// you to add additional key mappings to the help menu without // you to add additional key mappings to the help menu without
// re-implementing the help component. Of course, you can also disable the // re-implementing the help component. Of course, you can also disable the
@@ -149,8 +176,8 @@ type Model struct {
delegate ItemDelegate delegate ItemDelegate
} }
// NewModel returns a new model with sensible defaults. // New returns a new model with sensible defaults.
func NewModel(items []Item, delegate ItemDelegate, width, height int) Model { func New(items []Item, delegate ItemDelegate, width, height int) Model {
styles := DefaultStyles() styles := DefaultStyles()
sp := spinner.NewModel() sp := spinner.NewModel()
@@ -177,6 +204,7 @@ func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
showHelp: true, showHelp: true,
filteringEnabled: true, filteringEnabled: true,
KeyMap: DefaultKeyMap(), KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
Styles: styles, Styles: styles,
Title: "List", Title: "List",
FilterInput: filterInput, FilterInput: filterInput,
@@ -196,6 +224,11 @@ func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
return m return m
} }
// NewModel returns a new model with sensible defaults.
//
// Deprecated. Use New instead.
var NewModel = New
// SetFilteringEnabled enables or disables filtering. Note that this is different // SetFilteringEnabled enables or disables filtering. Note that this is different
// from ShowFilter, which merely hides or shows the input view. // from ShowFilter, which merely hides or shows the input view.
func (m *Model) SetFilteringEnabled(v bool) { func (m *Model) SetFilteringEnabled(v bool) {
@@ -292,6 +325,7 @@ func (m *Model) SetItems(i []Item) tea.Cmd {
} }
m.updatePagination() m.updatePagination()
m.updateKeybindings()
return cmd return cmd
} }
@@ -324,7 +358,8 @@ func (m *Model) SetItem(index int, item Item) tea.Cmd {
return cmd return cmd
} }
// Insert an item at the given index. This returns a command. // Insert an item at the given index. If index is out of the upper bound, the
// item will be appended. This returns a command.
func (m *Model) InsertItem(index int, item Item) tea.Cmd { func (m *Model) InsertItem(index int, item Item) tea.Cmd {
var cmd tea.Cmd var cmd tea.Cmd
m.items = insertItemIntoSlice(m.items, item, index) m.items = insertItemIntoSlice(m.items, item, index)
@@ -334,6 +369,7 @@ func (m *Model) InsertItem(index int, item Item) tea.Cmd {
} }
m.updatePagination() m.updatePagination()
m.updateKeybindings()
return cmd return cmd
} }
@@ -519,6 +555,7 @@ func (m *Model) StopSpinner() {
// Helper for disabling the keybindings used for quitting, incase you want to // Helper for disabling the keybindings used for quitting, incase you want to
// handle this elsewhere in your application. // handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() { func (m *Model) DisableQuitKeybindings() {
m.disableQuitKeybindings = true
m.KeyMap.Quit.SetEnabled(false) m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ForceQuit.SetEnabled(false) m.KeyMap.ForceQuit.SetEnabled(false)
} }
@@ -601,12 +638,12 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(false) m.KeyMap.ClearFilter.SetEnabled(false)
m.KeyMap.CancelWhileFiltering.SetEnabled(true) m.KeyMap.CancelWhileFiltering.SetEnabled(true)
m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "")
m.KeyMap.Quit.SetEnabled(true) m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ShowFullHelp.SetEnabled(false) m.KeyMap.ShowFullHelp.SetEnabled(false)
m.KeyMap.CloseFullHelp.SetEnabled(false) m.KeyMap.CloseFullHelp.SetEnabled(false)
default: default:
hasItems := m.items != nil hasItems := len(m.items) != 0
m.KeyMap.CursorUp.SetEnabled(hasItems) m.KeyMap.CursorUp.SetEnabled(hasItems)
m.KeyMap.CursorDown.SetEnabled(hasItems) m.KeyMap.CursorDown.SetEnabled(hasItems)
@@ -621,7 +658,7 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied)
m.KeyMap.CancelWhileFiltering.SetEnabled(false) m.KeyMap.CancelWhileFiltering.SetEnabled(false)
m.KeyMap.AcceptWhileFiltering.SetEnabled(false) m.KeyMap.AcceptWhileFiltering.SetEnabled(false)
m.KeyMap.Quit.SetEnabled(true) m.KeyMap.Quit.SetEnabled(!m.disableQuitKeybindings)
if m.Help.ShowAll { if m.Help.ShowAll {
m.KeyMap.ShowFullHelp.SetEnabled(true) m.KeyMap.ShowFullHelp.SetEnabled(true)
@@ -687,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
} }
case filterMatchesMsg: case FilterMatchesMsg:
m.filteredItems = filteredItems(msg) m.filteredItems = filteredItems(msg)
return m, nil return m, nil
@@ -1077,7 +1114,7 @@ func (m Model) populatedView() string {
if m.filterState == Filtering { if m.filterState == Filtering {
return "" return ""
} }
m.Styles.NoItems.Render("No items found.") return m.Styles.NoItems.Render("No items found.")
} }
if len(items) > 0 { if len(items) > 0 {
@@ -1118,7 +1155,7 @@ func (m Model) spinnerView() string {
func filterItems(m Model) tea.Cmd { func filterItems(m Model) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if m.FilterInput.Value() == "" || m.filterState == Unfiltered { if m.FilterInput.Value() == "" || m.filterState == Unfiltered {
return filterMatchesMsg(m.itemsAsFilterItems()) // return nothing return FilterMatchesMsg(m.itemsAsFilterItems()) // return nothing
} }
targets := []string{} targets := []string{}
@@ -1128,18 +1165,15 @@ func filterItems(m Model) tea.Cmd {
targets = append(targets, t.FilterValue()) targets = append(targets, t.FilterValue())
} }
var ranks fuzzy.Matches = fuzzy.Find(m.FilterInput.Value(), targets)
sort.Stable(ranks)
filterMatches := []filteredItem{} filterMatches := []filteredItem{}
for _, r := range ranks { for _, r := range m.Filter(m.FilterInput.Value(), targets) {
filterMatches = append(filterMatches, filteredItem{ filterMatches = append(filterMatches, filteredItem{
item: items[r.Index], item: items[r.Index],
matches: r.MatchedIndexes, matches: r.MatchedIndexes,
}) })
} }
return filterMatchesMsg(filterMatches) return FilterMatchesMsg(filterMatches)
} }
} }

View File

@@ -96,8 +96,8 @@ func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1 return m.Page == m.TotalPages-1
} }
// NewModel creates a new model with defaults. // New creates a new model with defaults.
func NewModel() Model { func New() Model {
return Model{ return Model{
Type: Arabic, Type: Arabic,
Page: 0, Page: 0,
@@ -114,6 +114,11 @@ func NewModel() Model {
} }
} }
// NewModel creates a new model with defaults.
//
// Deprecated. Use New instead.
var NewModel = New
// Update is the Tea update function which binds keystrokes to pagination. // Update is the Tea update function which binds keystrokes to pagination.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {

View File

@@ -31,10 +31,11 @@ func nextID() int {
} }
const ( const (
fps = 60 fps = 60
defaultWidth = 40 defaultWidth = 40
defaultFrequency = 18.0 defaultFrequency = 18.0
defaultDamping = 1.0 defaultDamping = 1.0
defaultAnimThreshold = 0.08
) )
var color func(string) termenv.Color = termenv.ColorProfile().Color var color func(string) termenv.Color = termenv.ColorProfile().Color
@@ -98,6 +99,11 @@ func WithWidth(w int) Option {
} }
} }
// WithSpringOptions sets the initial frequency and damping options for the
// progressbar's built-in spring-based animation. Frequency corresponds to
// speed, and damping to bounciness. For details see:
//
// https://github.com/charmbracelet/harmonica
func WithSpringOptions(frequency, damping float64) Option { func WithSpringOptions(frequency, damping float64) Option {
return func(m *Model) { return func(m *Model) {
m.SetSpringOptions(frequency, damping) m.SetSpringOptions(frequency, damping)
@@ -105,6 +111,14 @@ func WithSpringOptions(frequency, damping float64) Option {
} }
} }
// WithAnimationThreshold sets the percent chagne threshold necessary to
// trigger an animated transition.
func WithAnimationThreshold(ratio float64) Option {
return func(m *Model) {
m.SetAnimationThreshold(ratio)
}
}
// FrameMsg indicates that an animation step should occur. // FrameMsg indicates that an animation step should occur.
type FrameMsg struct { type FrameMsg struct {
id int id int
@@ -136,13 +150,17 @@ type Model struct {
PercentFormat string // a fmt string for a float PercentFormat string // a fmt string for a float
PercentageStyle lipgloss.Style PercentageStyle lipgloss.Style
// Members for animated transitions. // Settings for animated transitions.
spring harmonica.Spring spring harmonica.Spring
springCustomized bool springCustomized bool
percent float64 percentShown float64 // percent currently displaying
targetPercent float64 targetPercent float64 // percent to which we're animating
velocity float64 velocity float64
// The amount of change required to trigger an animated transition. Should
// be a float between 0 and 1.
animThreshold float64
// Gradient settings // Gradient settings
useRamp bool useRamp bool
rampColorA colorful.Color rampColorA colorful.Color
@@ -154,8 +172,8 @@ type Model struct {
scaleRamp bool scaleRamp bool
} }
// NewModel returns a model with default values. // New returns a model with default values.
func NewModel(opts ...Option) Model { func New(opts ...Option) Model {
m := Model{ m := Model{
id: nextID(), id: nextID(),
Width: defaultWidth, Width: defaultWidth,
@@ -176,6 +194,11 @@ func NewModel(opts ...Option) Model {
return m return m
} }
// NewModel returns a model with default values.
//
// Deprecated. Use New instead.
var NewModel = New
// Init exists satisfy the tea.Model interface. // Init exists satisfy the tea.Model interface.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil return nil
@@ -193,12 +216,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// If we've more or less reached equilibrium, stop updating. // If we've more or less reached equilibrium, stop updating.
dist := math.Abs(m.percent - m.targetPercent) dist := math.Abs(m.percentShown - m.targetPercent)
if dist < 0.001 && m.velocity < 0.01 { if dist < 0.001 && m.velocity < 0.01 {
return m, nil return m, nil
} }
m.percent, m.velocity = m.spring.Update(m.percent, m.velocity, m.targetPercent) m.percentShown, m.velocity = m.spring.Update(m.percentShown, m.velocity, m.targetPercent)
return m, m.nextFrame() return m, m.nextFrame()
default: default:
@@ -208,12 +231,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// SetSpringOptions sets the frequency and damping for the current spring. // SetSpringOptions sets the frequency and damping for the current spring.
// Frequency corresponds to speed, and damping to bounciness. For details see: // Frequency corresponds to speed, and damping to bounciness. For details see:
// https://github.com/charmbracelet/harmonica. //
// https://github.com/charmbracelet/harmonica
func (m *Model) SetSpringOptions(frequency, damping float64) { func (m *Model) SetSpringOptions(frequency, damping float64) {
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping) m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
} }
// Percent returns the current percentage state of the model. This is only // Percent returns the current visible percentage on the model. This is only
// relevant when you're animating the progress bar. // relevant when you're animating the progress bar.
// //
// If you're rendering with ViewAs you won't need this. // If you're rendering with ViewAs you won't need this.
@@ -226,7 +250,14 @@ func (m Model) Percent() float64 {
// //
// If you're rendering with ViewAs you won't need this. // If you're rendering with ViewAs you won't need this.
func (m *Model) SetPercent(p float64) tea.Cmd { func (m *Model) SetPercent(p float64) tea.Cmd {
m.targetPercent = math.Max(0, math.Min(1, p)) // If the value is at or below the animation threshold, don't animate
if math.Abs(p-m.percentShown) <= m.animThreshold {
m.percentShown = asRatio(p)
m.targetPercent = asRatio(p)
return nil
}
m.targetPercent = asRatio(p)
m.tag++ m.tag++
return m.nextFrame() return m.nextFrame()
} }
@@ -247,10 +278,22 @@ func (m *Model) DecrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() - v) return m.SetPercent(m.Percent() - v)
} }
// SetAnimationThreshold sets the percent chagne threshold necessary to trigger
// an animated transition.
func (m *Model) SetAnimationThreshold(v float64) {
m.animThreshold = asRatio(v)
}
// AnimationThreshold returns the percent change necessary to trigger an
// animated transition.
func (m *Model) AnimationThreshold() float64 {
return m.animThreshold
}
// View renders the an animated progress bar in its current state. To render // 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. // a static progress bar based on your own calculations use ViewAs instead.
func (m Model) View() string { func (m Model) View() string {
return m.ViewAs(m.percent) return m.ViewAs(m.percentShown)
} }
// ViewAs renders the progress bar with a given percentage. // ViewAs renders the progress bar with a given percentage.
@@ -340,3 +383,7 @@ func min(a, b int) int {
} }
return b return b
} }
func asRatio(v float64) float64 {
return math.Max(math.Min(v, 1), 0)
}

View File

@@ -1,14 +1,28 @@
package spinner package spinner
import ( import (
"strings" "sync"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi"
) )
// Internal ID management for text inputs. Necessary for blink integrity when
// multiple text inputs are involved.
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
}
// Spinner is a set of frames used in animating the spinner. // Spinner is a set of frames used in animating the spinner.
type Spinner struct { type Spinner struct {
Frames []string Frames []string
@@ -69,117 +83,35 @@ type Model struct {
// https://github.com/charmbracelet/lipgloss // https://github.com/charmbracelet/lipgloss
Style lipgloss.Style 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
// spinner. If HideFor is set MinimumLifetime will be added on top of
// HideFor. In other words, if HideFor is 100ms and MinimumLifetime is
// 200ms then MinimumLifetime will expire after 300ms.
//
// MinimumLifetime is optional.
//
// This is considered experimental and may not appear in future versions of
// this library.
MinimumLifetime time.Duration
// HideFor can be used to wait to show the spinner until a certain amount
// of time has passed. This can be useful for preventing flicking when load
// times are very fast.
// Optional.
//
// This is considered experimental and may not appear in future versions of
// this library.
HideFor time.Duration
frame int frame int
startTime time.Time startTime time.Time
id int
tag int tag int
} }
// Start resets resets the spinner start time. For use with MinimumLifetime and // ID returns the spinner's unique ID.
// MinimumStartTime. Optional. func (m Model) ID() int {
// return m.id
// 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 // New returns a model with default values.
// isn't flagged to be showing. If it is showing, finish has no effect. The func New() Model {
// idea here is that you call Finish if your operation has completed and, if return Model{
// the spinner isn't showing yet (by virtue of HideFor) then Visible() doesn't Spinner: Line,
// show the spinner at all. id: nextID(),
//
// 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() {
return false
}
if m.HideFor == 0 {
return false
}
return m.startTime.Add(m.HideFor).After(time.Now())
}
// finished returns whether the minimum lifetime of this spinner has been
// exceeded.
func (m Model) finished() bool {
if m.startTime.IsZero() || m.MinimumLifetime == 0 {
return true
}
return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now())
}
// Visible returns whether or not the view should be rendered. Works in
// conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should
// 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.
//
// 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) Visible() bool {
return !m.hidden() && !m.finished()
}
// NewModel returns a model with default values. // NewModel returns a model with default values.
func NewModel() Model { //
return Model{Spinner: Line} // Deprecated. Use New instead.
} var NewModel = New
// 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 tag int
ID int
} }
// 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
@@ -188,6 +120,12 @@ type TickMsg struct {
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case TickMsg: case TickMsg:
// If an ID is set, and the ID doesn't belong to this spinner, reject
// the message.
if msg.ID > 0 && msg.ID != m.id {
return m, nil
}
// If a tag is set, and it's not the one we expect, reject the message. // 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 // This prevents the spinner from receiving too many messages and
// thus spinning too fast. // thus spinning too fast.
@@ -201,7 +139,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
m.tag++ m.tag++
return m, m.tick(m.tag) return m, m.tick(m.id, m.tag)
default: default:
return m, nil return m, nil
} }
@@ -213,29 +151,39 @@ func (m Model) View() string {
return "(error)" return "(error)"
} }
frame := m.Spinner.Frames[m.frame] return m.Style.Render(m.Spinner.Frames[m.frame])
// 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 m.Style.Render(frame)
} }
// Tick is the command used to advance the spinner one frame. Use this command // Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner. // to effectively start the spinner.
func Tick() tea.Msg { func (m Model) Tick() tea.Msg {
return TickMsg{Time: time.Now()} return TickMsg{
// The time at which the tick occurred.
Time: time.Now(),
// The ID of the spinner that this message belongs to. This can be
// helpful when routing messages, however bear in mind that spinners
// will ignore messages that don't contain ID by default.
ID: m.id,
tag: m.tag,
}
} }
func (m Model) tick(tag int) tea.Cmd { func (m Model) tick(id, tag int) tea.Cmd {
return tea.Tick(m.Spinner.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,
ID: id,
tag: tag, tag: tag,
} }
}) })
} }
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
//
// This method is deprecated. Use Model.Tick instead.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}

152
stopwatch/stopwatch.go Normal file
View File

@@ -0,0 +1,152 @@
// Package stopwatch provides a simple stopwatch component.
package stopwatch
import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
)
var (
lastID int
idMtx sync.Mutex
)
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// TickMsg is a message that is sent on every timer tick.
type TickMsg struct {
// ID is the identifier of the stopwatch that send the message. This makes
// it possible to determine which stopwatch a tick belongs to when there
// are multiple stopwatches running.
//
// Note, however, that a stopwatch will reject ticks from other
// stopwatches, so it's safe to flow all TickMsgs through all stopwatches
// and have them still behave appropriately.
ID int
}
// StartStopMsg is sent when the stopwatch should start or stop.
type StartStopMsg struct {
ID int
running bool
}
// ResetMsg is sent when the stopwatch should reset.
type ResetMsg struct {
ID int
}
// Model for the stopwatch component.
type Model struct {
d time.Duration
id int
running bool
// How long to wait before every tick. Defaults to 1 second.
Interval time.Duration
}
// NewWithInterval creates a new stopwatch with the given timeout and tick
// interval.
func NewWithInterval(interval time.Duration) Model {
return Model{
Interval: interval,
id: nextID(),
}
}
// New creates a new stopwatch with 1s interval.
func New() Model {
return NewWithInterval(time.Second)
}
// ID returns the unique ID of the model.
func (m Model) ID() int {
return m.id
}
// Init starts the stopwatch.
func (m Model) Init() tea.Cmd {
return m.Start()
}
// Start starts the stopwatch.
func (m Model) Start() tea.Cmd {
return tea.Batch(func() tea.Msg {
return StartStopMsg{ID: m.id, running: true}
}, tick(m.id, m.Interval))
}
// Stop stops the stopwatch.
func (m Model) Stop() tea.Cmd {
return func() tea.Msg {
return StartStopMsg{ID: m.id, running: false}
}
}
// Toggle stops the stopwatch if it is running and starts it if it is stopped.
func (m Model) Toggle() tea.Cmd {
if m.Running() {
return m.Stop()
}
return m.Start()
}
// Reset restes the stopwatch to 0.
func (m Model) Reset() tea.Cmd {
return func() tea.Msg {
return ResetMsg{ID: m.id}
}
}
// Running returns true if the stopwatch is running or false if it is stopped.
func (m Model) Running() bool {
return m.running
}
// Update handles the timer tick.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case StartStopMsg:
if msg.ID != m.id {
return m, nil
}
m.running = msg.running
case ResetMsg:
if msg.ID != m.id {
return m, nil
}
m.d = 0
case TickMsg:
if !m.running || msg.ID != m.id {
break
}
m.d += m.Interval
return m, tick(m.id, m.Interval)
}
return m, nil
}
// Elapsed returns the time elapsed.
func (m Model) Elapsed() time.Duration {
return m.d
}
// View of the timer component.
func (m Model) View() string {
return m.d.String()
}
func tick(id int, d time.Duration) tea.Cmd {
return tea.Tick(d, func(_ time.Time) tea.Msg {
return TickMsg{ID: id}
})
}

View File

@@ -62,7 +62,7 @@ const (
// seen for password fields on the command line. // seen for password fields on the command line.
EchoNone EchoNone
// EchoOnEdit // EchoOnEdit.
) )
// blinkCtx manages cursor blinking. // blinkCtx manages cursor blinking.
@@ -153,7 +153,7 @@ type Model struct {
} }
// NewModel creates a new model with default settings. // NewModel creates a new model with default settings.
func NewModel() Model { func New() Model {
return Model{ return Model{
Prompt: "> ", Prompt: "> ",
BlinkSpeed: defaultBlinkSpeed, BlinkSpeed: defaultBlinkSpeed,
@@ -174,6 +174,11 @@ func NewModel() Model {
} }
} }
// NewModel creates a new model with default settings.
//
// Deprecated. Use New instead.
var NewModel = New
// SetValue sets the value of the text input. // SetValue sets the value of the text input.
func (m *Model) SetValue(s string) { func (m *Model) SetValue(s string) {
runes := []rune(s) runes := []rune(s)
@@ -198,6 +203,11 @@ func (m Model) Cursor() int {
return m.pos return m.pos
} }
// Blink returns whether or not to draw the cursor.
func (m Model) Blink() bool {
return m.blink
}
// SetCursor moves the cursor to the given position. If the position is // 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.
func (m *Model) SetCursor(pos int) { func (m *Model) SetCursor(pos int) {
@@ -229,7 +239,7 @@ func (m *Model) cursorStart() bool {
return m.setCursor(0) return m.setCursor(0)
} }
// CursorEnd moves the cursor to the end of the input field // CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() { func (m *Model) CursorEnd() {
m.cursorEnd() m.cursorEnd()
} }
@@ -407,9 +417,16 @@ func (m *Model) deleteWordLeft() bool {
return m.deleteBeforeCursor() return m.deleteBeforeCursor()
} }
i := m.pos // Linter note: it's critical that we acquire the initial cursor position
// here prior to altering it via SetCursor() below. As such, moving this
// call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort
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]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor // ignore series of whitespace before cursor
blink = m.setCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
} }
@@ -426,10 +443,10 @@ func (m *Model) deleteWordLeft() bool {
} }
} }
if i > len(m.value) { if oldPos > len(m.value) {
m.value = m.value[:m.pos] m.value = m.value[:m.pos]
} else { } else {
m.value = append(m.value[:m.pos], m.value[i:]...) m.value = append(m.value[:m.pos], m.value[oldPos:]...)
} }
return blink return blink
@@ -447,11 +464,15 @@ func (m *Model) deleteWordRight() bool {
return m.deleteAfterCursor() return m.deleteAfterCursor()
} }
i := m.pos oldPos := m.pos
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
m.setCursor(m.pos + 1) m.setCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
}
} }
for m.pos < len(m.value) { for m.pos < len(m.value) {
@@ -463,12 +484,12 @@ func (m *Model) deleteWordRight() bool {
} }
if m.pos > len(m.value) { if m.pos > len(m.value) {
m.value = m.value[:i] m.value = m.value[:oldPos]
} else { } else {
m.value = append(m.value[:i], m.value[m.pos:]...) m.value = append(m.value[:oldPos], m.value[m.pos:]...)
} }
return m.setCursor(i) return m.setCursor(oldPos)
} }
// 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
@@ -784,6 +805,9 @@ func Paste() tea.Msg {
} }
func clamp(v, low, high int) int { func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v)) return min(high, max(low, v))
} }

194
timer/timer.go Normal file
View File

@@ -0,0 +1,194 @@
// Package timer provides a simple timeout component.
package timer
import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
)
var (
lastID int
idMtx sync.Mutex
)
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// Authors note with regard to start and stop commands:
//
// Technically speaking, sending commands to start and stop the timer in this
// case is extraneous. To stop the timer we'd just need to set the 'running'
// property on the model to false which cause logic in the update function to
// stop responding to TickMsgs. To start the model we'd set 'running' to true
// and fire off a TickMsg. Helper functions would look like:
//
// func (m *model) Start() tea.Cmd
// func (m *model) Stop()
//
// The danger with this approach, however, is that order of operations becomes
// important with helper functions like the above. Consider the following:
//
// // Would not work
// return m, m.timer.Start()
//
// // Would work
// cmd := m.timer.start()
// return m, cmd
//
// Thus, because of potential pitfalls like the ones above, we've introduced
// the extraneous StartStopMsg to simplify the mental model when using this
// package. Bear in mind that the practice of sending commands to simply
// communicate with other parts of your application, such as in this package,
// is still not recommended.
// StartStopMsg is used to start and stop the timer.
type StartStopMsg struct {
ID int
running bool
}
// TickMsg is a message that is sent on every timer tick.
type TickMsg struct {
// ID is the identifier of the stopwatch that send the message. This makes
// it possible to determine which timer a tick belongs to when there
// are multiple timers running.
//
// Note, however, that a timer will reject ticks from other stopwatches, so
// it's safe to flow all TickMsgs through all timers and have them still
// behave appropriately.
ID int
// Timeout returns whether or not this tick is a timeout tick. You can
// alternatively listen for TimeoutMsg.
Timeout bool
}
// TimeoutMsg is a message that is sent once when the timer times out.
//
// It's a convenience message sent alongside a TickMsg with the Timeout value
// set to true.
type TimeoutMsg struct {
ID int
}
// Model of the timer component.
type Model struct {
// How long until the timer expires.
Timeout time.Duration
// How long to wait before every tick. Defaults to 1 second.
Interval time.Duration
id int
running bool
}
// NewWithInterval creates a new timer with the given timeout and tick interval.
func NewWithInterval(timeout, interval time.Duration) Model {
return Model{
Timeout: timeout,
Interval: interval,
running: true,
id: nextID(),
}
}
// New creates a new timer with the given timeout and default 1s interval.
func New(timeout time.Duration) Model {
return NewWithInterval(timeout, time.Second)
}
// ID returns the model's identifier. This can be used to determine if messages
// belong to this timer instance when there are multiple timers.
func (m Model) ID() int {
return m.id
}
// Running returns whether or not the timer is running. If the timer has timed
// out this will always return false.
func (m Model) Running() bool {
if m.Timedout() || !m.running {
return false
}
return true
}
// Timedout returns whether or not the timer has timed out.
func (m Model) Timedout() bool {
return m.Timeout <= 0
}
// Init starts the timer.
func (m Model) Init() tea.Cmd {
return m.tick()
}
// Update handles the timer tick.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case StartStopMsg:
if msg.ID != 0 && msg.ID != m.id {
return m, nil
}
m.running = msg.running
return m, m.tick()
case TickMsg:
if !m.Running() || (msg.ID != 0 && msg.ID != m.id) {
break
}
m.Timeout -= m.Interval
return m, tea.Batch(m.tick(), m.timedout())
}
return m, nil
}
// View of the timer component.
func (m Model) View() string {
return m.Timeout.String()
}
// Start resumes the timer. Has no effect if the timer has timed out.
func (m *Model) Start() tea.Cmd {
return m.startStop(true)
}
// Stop pauses the timer. Has no effect if the timer has timed out.
func (m *Model) Stop() tea.Cmd {
return func() tea.Msg {
return m.startStop(false)
}
}
// Toggle stops the timer if it's running and starts it if it's stopped.
func (m *Model) Toggle() tea.Cmd {
return m.startStop(!m.Running())
}
func (m Model) tick() tea.Cmd {
return tea.Tick(m.Interval, func(_ time.Time) tea.Msg {
return TickMsg{ID: m.id, Timeout: m.Timedout()}
})
}
func (m Model) timedout() tea.Cmd {
if !m.Timedout() {
return nil
}
return func() tea.Msg {
return TimeoutMsg{ID: m.id}
}
}
func (m Model) startStop(v bool) tea.Cmd {
return func() tea.Msg {
return StartStopMsg{ID: m.id, running: v}
}
}

42
viewport/keymap.go Normal file
View File

@@ -0,0 +1,42 @@
package viewport
import "github.com/charmbracelet/bubbles/key"
const spacebar = " "
// KeyMap defines the keybindings for the viewport. Note that you don't
// necessary need to use keybindings at all; the viewport can be controlled
// programmatically with methods like Model.LineDown(1). See the GoDocs for
// details.
type KeyMap struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
Down key.Binding
Up key.Binding
}
// DefaultKeyMap returns a set of pager-like default keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
),
}
}

View File

@@ -4,26 +4,44 @@ import (
"math" "math"
"strings" "strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
const ( // New returns a new model with the given width and height as well as default
spacebar = " " // keymappings.
mouseWheelDelta = 3 func New(width, height int) (m Model) {
) m.Width = width
m.Height = height
m.setInitialValues()
return m
}
// Model is the Bubble Tea model for this viewport element. // Model is the Bubble Tea model for this viewport element.
type Model struct { type Model struct {
Width int Width int
Height int Height int
KeyMap KeyMap
// Whether or not to respond to the mouse. The mouse must be enabled in
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
MouseWheelEnabled bool
// The number of lines the mouse wheel will scroll. By default, this is 3.
MouseWheelDelta int
// YOffset is the vertical scroll position. // YOffset is the vertical scroll position.
YOffset int YOffset int
// YPosition is the position of the viewport in relation to the terminal // YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering. // window. It's used in high performance rendering only.
YPosition int YPosition int
// Style applies a lipgloss style to the viewport. Realistically, it's most
// useful for setting borders, margins and padding.
Style lipgloss.Style
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to // HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble // provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with // Tea rendering methods will suffice, but if you're passing content with
@@ -34,7 +52,20 @@ type Model struct {
// which is usually via the alternate screen buffer. // which is usually via the alternate screen buffer.
HighPerformanceRendering bool HighPerformanceRendering bool
lines []string initialized bool
lines []string
}
func (m *Model) setInitialValues() {
m.KeyMap = DefaultKeyMap()
m.MouseWheelEnabled = true
m.MouseWheelDelta = 3
m.initialized = true
}
// Init exists to satisfy the tea.Model interface for composability purposes.
func (m Model) Init() tea.Cmd {
return nil
} }
// AtTop returns whether or not the viewport is in the very top position. // AtTop returns whether or not the viewport is in the very top position.
@@ -45,13 +76,13 @@ func (m Model) AtTop() bool {
// AtBottom returns whether or not the viewport is at or past the very bottom // AtBottom returns whether or not the viewport is at or past the very bottom
// position. // position.
func (m Model) AtBottom() bool { func (m Model) AtBottom() bool {
return m.YOffset >= len(m.lines)-1-m.Height return m.YOffset >= m.maxYOffset()
} }
// PastBottom returns whether or not the viewport is scrolled beyond the last // PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height. // line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool { func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height return m.YOffset > m.maxYOffset()
} }
// ScrollPercent returns the amount scrolled as a float between 0 and 1. // ScrollPercent returns the amount scrolled as a float between 0 and 1.
@@ -69,7 +100,7 @@ func (m Model) ScrollPercent() float64 {
// SetContent set the pager's text content. For high performance rendering the // SetContent set the pager's text content. For high performance rendering the
// Sync command should also be called. // Sync command should also be called.
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.ReplaceAll(s, "\r\n", "\n") // normalize line endings
m.lines = strings.Split(s, "\n") m.lines = strings.Split(s, "\n")
if m.YOffset > len(m.lines)-1 { if m.YOffset > len(m.lines)-1 {
@@ -77,7 +108,14 @@ func (m *Model) SetContent(s string) {
} }
} }
// Return the lines that should currently be visible in the viewport. // maxYOffset returns the maximum possible value of the y-offset based on the
// viewport's content and set height.
func (m Model) maxYOffset() int {
return max(0, len(m.lines)-m.Height)
}
// visibleLines returns the lines that should currently be visible in the
// viewport.
func (m Model) visibleLines() (lines []string) { func (m Model) visibleLines() (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(0, m.YOffset) top := max(0, m.YOffset)
@@ -87,6 +125,21 @@ func (m Model) visibleLines() (lines []string) {
return lines return lines
} }
// scrollArea returns the scrollable boundaries for high performance rendering.
func (m Model) scrollArea() (top, bottom int) {
top = max(0, m.YPosition)
bottom = max(top, top+m.Height)
if top > 0 && bottom > top {
bottom--
}
return top, bottom
}
// SetYOffset sets the Y offset.
func (m *Model) SetYOffset(n int) {
m.YOffset = clamp(n, 0, m.maxYOffset())
}
// ViewDown moves the view down by the number of lines in the viewport. // ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down". // Basically, "page down".
func (m *Model) ViewDown() []string { func (m *Model) ViewDown() []string {
@@ -94,11 +147,7 @@ func (m *Model) ViewDown() []string {
return nil return nil
} }
m.YOffset = min( m.SetYOffset(m.YOffset + m.Height)
m.YOffset+m.Height, // target
len(m.lines)-1-m.Height, // fallback
)
return m.visibleLines() return m.visibleLines()
} }
@@ -108,11 +157,7 @@ func (m *Model) ViewUp() []string {
return nil return nil
} }
m.YOffset = max( m.SetYOffset(m.YOffset - m.Height)
m.YOffset-m.Height, // target
0, // fallback
)
return m.visibleLines() return m.visibleLines()
} }
@@ -122,18 +167,8 @@ func (m *Model) HalfViewDown() (lines []string) {
return nil return nil
} }
m.YOffset = min( m.SetYOffset(m.YOffset + m.Height/2)
m.YOffset+m.Height/2, // target return m.visibleLines()
len(m.lines)-1-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height/2, 0)
bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// HalfViewUp moves the view up by half the height of the viewport. // HalfViewUp moves the view up by half the height of the viewport.
@@ -142,18 +177,8 @@ func (m *Model) HalfViewUp() (lines []string) {
return nil return nil
} }
m.YOffset = max( m.SetYOffset(m.YOffset - m.Height/2)
m.YOffset-m.Height/2, // target return m.visibleLines()
0, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset, 0)
bottom := clamp(m.YOffset+m.Height/2, top, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// LineDown moves the view down by the given number of lines. // LineDown moves the view down by the given number of lines.
@@ -165,21 +190,8 @@ func (m *Model) LineDown(n int) (lines []string) {
// Make sure the number of lines by which we're going to scroll isn't // Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach // greater than the number of lines we actually have left before we reach
// the bottom. // the bottom.
maxDelta := (len(m.lines) - 1) - (m.YOffset + m.Height) // number of lines - viewport bottom edge m.SetYOffset(m.YOffset + n)
n = min(n, maxDelta) return m.visibleLines()
m.YOffset = min(
m.YOffset+n, // target
len(m.lines)-1-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height-n, 0)
bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// LineUp moves the view down by the given number of lines. Returns the new // LineUp moves the view down by the given number of lines. Returns the new
@@ -191,17 +203,8 @@ func (m *Model) LineUp(n int) (lines []string) {
// Make sure the number of lines by which we're going to scroll isn't // Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top. // greater than the number of lines we are from the top.
n = min(n, m.YOffset) m.SetYOffset(m.YOffset - n)
return m.visibleLines()
m.YOffset = max(m.YOffset-n, 0)
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+n, top, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// GotoTop sets the viewport to the top position. // GotoTop sets the viewport to the top position.
@@ -210,32 +213,16 @@ func (m *Model) GotoTop() (lines []string) {
return nil return nil
} }
m.YOffset = 0 m.SetYOffset(0)
return m.visibleLines()
if len(m.lines) > 0 {
top := m.YOffset
bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// GotoBottom 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.SetYOffset(m.maxYOffset())
return m.visibleLines()
if len(m.lines) > 0 {
top := m.YOffset
bottom := max(len(m.lines)-1, 0)
lines = m.lines[top:bottom]
}
return lines
} }
// COMMANDS
// Sync tells the renderer where the viewport will be located and requests // Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the // a render of the current state of the viewport. It should be called for the
// first render and after a window resize. // first render and after a window resize.
@@ -245,17 +232,8 @@ func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 { if len(m.lines) == 0 {
return nil return nil
} }
top, bottom := m.scrollArea()
// TODO: we should probably use m.visibleLines() rather than these two return tea.SyncScrollArea(m.visibleLines(), top, bottom)
// expressions.
top := max(m.YOffset, 0)
bottom := clamp(m.YOffset+m.Height, 0, len(m.lines)-1)
return tea.SyncScrollArea(
m.lines[top:bottom],
m.YPosition,
m.YPosition+m.Height,
)
} }
// ViewDown is a high performance command that moves the viewport up by a given // ViewDown is a high performance command that moves the viewport up by a given
@@ -269,7 +247,8 @@ func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 { if len(lines) == 0 {
return nil return nil
} }
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height) top, bottom := m.scrollArea()
return tea.ScrollDown(lines, top, bottom)
} }
// ViewUp is a high performance command the moves the viewport down by a given // ViewUp is a high performance command the moves the viewport down by a given
@@ -279,57 +258,60 @@ func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 { if len(lines) == 0 {
return nil return nil
} }
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height) top, bottom := m.scrollArea()
return tea.ScrollUp(lines, top, bottom)
} }
// UPDATE // Update handles standard message-based viewport updates.
// Update runs the update loop with default keybindings similar to popular
// pagers. To define your own keybindings use the methods on Model (i.e.
// Model.LineDown()) and define your own update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m, cmd = m.updateAsModel(msg)
return m, cmd
}
// Author's note: this method has been broken out to make it easier to
// potentially transition Update to satisfy tea.Model.
func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
if !m.initialized {
m.setInitialValues()
}
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch {
// Down one page case key.Matches(msg, m.KeyMap.PageDown):
case "pgdown", spacebar, "f":
lines := m.ViewDown() lines := m.ViewDown()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up one page case key.Matches(msg, m.KeyMap.PageUp):
case "pgup", "b":
lines := m.ViewUp() lines := m.ViewUp()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
// Down half page case key.Matches(msg, m.KeyMap.HalfPageDown):
case "d", "ctrl+d":
lines := m.HalfViewDown() lines := m.HalfViewDown()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up half page case key.Matches(msg, m.KeyMap.HalfPageUp):
case "u", "ctrl+u":
lines := m.HalfViewUp() lines := m.HalfViewUp()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
// Down one line case key.Matches(msg, m.KeyMap.Down):
case "down", "j":
lines := m.LineDown(1) lines := m.LineDown(1)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up one line case key.Matches(msg, m.KeyMap.Up):
case "up", "k":
lines := m.LineUp(1) lines := m.LineUp(1)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
@@ -337,15 +319,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
case tea.MouseMsg: case tea.MouseMsg:
if !m.MouseWheelEnabled {
break
}
switch msg.Type { switch msg.Type {
case tea.MouseWheelUp: case tea.MouseWheelUp:
lines := m.LineUp(mouseWheelDelta) lines := m.LineUp(m.MouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
lines := m.LineDown(mouseWheelDelta) lines := m.LineDown(m.MouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
@@ -355,13 +340,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd return m, cmd
} }
// VIEW
// View renders the viewport into a string. // View renders the viewport into a string.
func (m Model) View() string { func (m Model) View() string {
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
// Just send newlines since we're doing to be rendering the actual // Just send newlines since we're going to be rendering the actual
// content seprately. We still need send something that equals the // content seprately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can // height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly. // position anything below this view properly.
return strings.Repeat("\n", m.Height-1) return strings.Repeat("\n", m.Height-1)
@@ -372,15 +355,19 @@ func (m Model) View() string {
// Fill empty space with newlines // Fill empty space with newlines
extraLines := "" extraLines := ""
if len(lines) < m.Height { if len(lines) < m.Height {
extraLines = strings.Repeat("\n", m.Height-len(lines)) extraLines = strings.Repeat("\n", max(0, m.Height-len(lines)))
} }
return strings.Join(lines, "\n") + extraLines return m.Style.Copy().
UnsetWidth().
UnsetHeight().
Render(strings.Join(lines, "\n") + extraLines)
} }
// ETC
func clamp(v, low, high int) int { func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v)) return min(high, max(low, v))
} }