65 Commits

Author SHA1 Message Date
Maas Lalani
2e55803b28 docs(list): fix linting errors 2022-06-16 18:30:36 -04:00
Weslei Juan Novaes Pereira
57d79daf4d feat(list): ability to SetStatusBarItemName (#169) 2022-06-16 18:14:47 -04:00
IllusionMan1212
e57fd292cc feat: added Validate function for textinput 2022-06-10 12:17:24 -04:00
Hironao OTSUBO
54869f7a1d docs(spinner): remove obsolete comment (#168)
The doc for spinner.Model.Update is obsolete as per 35c3cd626d,
which made Update aware of Msg's type.
2022-06-10 10:03:55 -04:00
Ayman Bagabas
7959eb4867 fix(progress): set a custom termenv color profile (#152) 2022-06-01 18:04:33 -07:00
Christian Rocha
fd03b6195d chore: bump bubbletea, harmonica, lipgloss and termenv deps 2022-06-01 17:53:14 -07:00
Christian Rocha
a1e1b461b6 fix(textinput): support KeySpace in both present and future Bubble Tea versions
Closes #144
2022-05-29 08:25:34 -07:00
Carlos Alexandro Becker
cd2593cfb7 feat: allow to set the height of the item (#155)
* feat: allow to set the height of the item

The user might want to show more than 2 lines, and, right now, if they
try to do so, things break.

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* fix: short-circuit if width <= 0

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* fix: height check

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
2022-05-24 15:00:35 -03:00
Tim Adler
e1871db6d3 fix: padding in titlebar when nothing is displayed (#139)
* Don't render anything, when nothing is shown

* fix linter issue
2022-05-24 09:05:39 -03:00
Christian Rocha
2a8d463bd1 chore: shorten half page up/down help widths 2022-04-29 12:20:18 -04:00
Ayman Bagabas
c214837839 fix: add viewport keymap help 2022-04-29 12:20:18 -04:00
Ayman Bagabas
f5ac64216b fix: lint warn 2022-04-29 12:20:18 -04:00
Christian Rocha
292a1dd7ba fix(spinner): remove unused member in model 2022-04-12 10:12:14 -04:00
Christian Rocha
154cdbc53a docs: add Tyler's Teacup library to resources 2022-04-12 10:11: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
17 changed files with 993 additions and 365 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
<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
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)
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.
[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)
## 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
<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
),
Down: key.NewBinding(
WithKeys("j", "down"),
WithHelp("↓/j", "move down"),
key.WithKeys("j", "down"),
key.WithHelp("↓/j", "move down"),
),
}
@@ -151,13 +171,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
## Additional Bubbles
* [promptkit](https://github.com/erikgeiser/promptkit): A collection of common
prompts for cases like selection, text input, and confirmation. Each prompt
comes with sensible defaults, remappable keybindings, any many customization
options.
<!-- in alphabetical order by author -->
* [76creates/stickers](https://github.com/76creates/stickers): Responsive
flexbox and table components.
* [calyptia/go-bubble-table](https://github.com/calyptia/go-bubble-table): An
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.
* [knipferrc/teacup](https://github.com/knipferrc/teacup): Various handy
bubbles and utilities for building Bubble Tea applications.
* [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose
bubbles. Inputs with validation, menu selection, a modified progressbar, and
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,
[let us know](mailto:vt100@charm.sh).
@@ -172,6 +207,8 @@ If youve built a Bubble you think should be listed here,
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]: https://charm.sh/

10
go.mod
View File

@@ -3,14 +3,14 @@ module github.com/charmbracelet/bubbles
go 1.13
require (
github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbletea v0.14.1
github.com/charmbracelet/harmonica v0.1.0
github.com/charmbracelet/lipgloss v0.3.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.21.0
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.5.0
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
github.com/sahilm/fuzzy v0.1.0
)

52
go.sum
View File

@@ -1,32 +1,33 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.14.1 h1:pD/bM5LBEH/nDo7nKcgNUgi4uRHQhpWTIHZbG5vuSlc=
github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE=
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.3.0 h1:5MysOD6sHr4RP4jkZNWGVIul5GKoOsP12NgbgXPvAlA=
github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-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.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/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/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
@@ -35,10 +36,11 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -57,8 +57,8 @@ type Model struct {
Styles Styles
}
// NewModel creates a new help view with some useful defaults.
func NewModel() Model {
// New creates a new help view with some useful defaults.
func New() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
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.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
@@ -159,8 +164,12 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
return ""
}
// Linter note: at this time we don't think it's worth the additional
// code complexity involved in preallocating this slice.
//nolint:prealloc
var (
out []string
out []string
totalWidth int
sep = m.Styles.FullSeparator.Render(m.FullSeparator)
sepWidth = lipgloss.Width(sep)

View File

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

View File

@@ -3,6 +3,7 @@ package list
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
@@ -86,6 +87,7 @@ type DefaultDelegate struct {
UpdateFunc func(tea.Msg, *Model) tea.Cmd
ShortHelpFunc func() []key.Binding
FullHelpFunc func() [][]key.Binding
height int
spacing int
}
@@ -94,14 +96,22 @@ func NewDefaultDelegate() DefaultDelegate {
return DefaultDelegate{
ShowDescription: true,
Styles: NewDefaultItemStyles(),
height: 2,
spacing: 1,
}
}
// SetHeight sets delegate's preferred height.
func (d *DefaultDelegate) SetHeight(i int) {
d.height = i
}
// Height returns the delegate's preferred height.
// This has effect only if ShowDescription is true,
// otherwise height is always 1.
func (d DefaultDelegate) Height() int {
if d.ShowDescription {
return 2 //nolint:gomnd
return d.height
}
return 1
}
@@ -139,11 +149,23 @@ func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) {
return
}
if m.width <= 0 {
// short-circuit
return
}
// Prevent text from exceeding list width
if m.width > 0 {
textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
title = truncate.StringWithTail(title, textwidth, ellipsis)
desc = truncate.StringWithTail(desc, textwidth, ellipsis)
textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
title = truncate.StringWithTail(title, textwidth, ellipsis)
if d.ShowDescription {
var lines []string
for i, line := range strings.Split(desc, "\n") {
if i >= d.height-1 {
break
}
lines = append(lines, truncate.StringWithTail(line, textwidth, ellipsis))
}
desc = strings.Join(lines, "\n")
}
// Conditions

View File

@@ -67,15 +67,37 @@ func (f filteredItems) items() []Item {
return agg
}
func (f filteredItems) matches() [][]int {
agg := make([][]int, len(f))
for i, v := range f {
agg[i] = v.matches
}
return agg
// FilterMatchesMsg contains data about items matched during filtering. The
// message should be routed to Update for processing.
type FilterMatchesMsg []filteredItem
// FilterFunc takes a term and a list of strings to search through
// (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.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{}
@@ -107,12 +129,20 @@ type Model struct {
showHelp bool
filteringEnabled bool
itemNameSingular string
itemNamePlural string
Title string
Styles Styles
// Key mappings for navigating the list.
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
// you to add additional key mappings to the help menu without
// re-implementing the help component. Of course, you can also disable the
@@ -149,8 +179,8 @@ type Model struct {
delegate ItemDelegate
}
// NewModel returns a new model with sensible defaults.
func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
// New returns a new model with sensible defaults.
func New(items []Item, delegate ItemDelegate, width, height int) Model {
styles := DefaultStyles()
sp := spinner.NewModel()
@@ -175,8 +205,11 @@ func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
showStatusBar: true,
showPagination: true,
showHelp: true,
itemNameSingular: "item",
itemNamePlural: "items",
filteringEnabled: true,
KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
Styles: styles,
Title: "List",
FilterInput: filterInput,
@@ -196,6 +229,11 @@ func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
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
// from ShowFilter, which merely hides or shows the input view.
func (m *Model) SetFilteringEnabled(v bool) {
@@ -253,7 +291,19 @@ func (m Model) ShowStatusBar() bool {
return m.showStatusBar
}
// ShowingPagination hides or shoes the paginator. Note that pagination will
// SetStatusBarItemName defines a replacement for the items identifier.
// Defaults to item/items.
func (m *Model) SetStatusBarItemName(singular, plural string) {
m.itemNameSingular = singular
m.itemNamePlural = plural
}
// StatusBarItemName returns singular and plural status bar item names.
func (m Model) StatusBarItemName() (string, string) {
return m.itemNameSingular, m.itemNamePlural
}
// SetShowPagination hides or shoes the paginator. Note that pagination will
// still be active, it simply won't be displayed.
func (m *Model) SetShowPagination(v bool) {
m.showPagination = v
@@ -292,6 +342,7 @@ func (m *Model) SetItems(i []Item) tea.Cmd {
}
m.updatePagination()
m.updateKeybindings()
return cmd
}
@@ -324,7 +375,8 @@ func (m *Model) SetItem(index int, item Item) tea.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 {
var cmd tea.Cmd
m.items = insertItemIntoSlice(m.items, item, index)
@@ -334,6 +386,7 @@ func (m *Model) InsertItem(index int, item Item) tea.Cmd {
}
m.updatePagination()
m.updateKeybindings()
return cmd
}
@@ -516,9 +569,10 @@ func (m *Model) StopSpinner() {
m.showSpinner = false
}
// Helper for disabling the keybindings used for quitting, incase you want to
// Helper for disabling the keybindings used for quitting, in case you want to
// handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() {
m.disableQuitKeybindings = true
m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ForceQuit.SetEnabled(false)
}
@@ -601,12 +655,12 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(false)
m.KeyMap.CancelWhileFiltering.SetEnabled(true)
m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "")
m.KeyMap.Quit.SetEnabled(true)
m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ShowFullHelp.SetEnabled(false)
m.KeyMap.CloseFullHelp.SetEnabled(false)
default:
hasItems := m.items != nil
hasItems := len(m.items) != 0
m.KeyMap.CursorUp.SetEnabled(hasItems)
m.KeyMap.CursorDown.SetEnabled(hasItems)
@@ -621,7 +675,7 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied)
m.KeyMap.CancelWhileFiltering.SetEnabled(false)
m.KeyMap.AcceptWhileFiltering.SetEnabled(false)
m.KeyMap.Quit.SetEnabled(true)
m.KeyMap.Quit.SetEnabled(!m.disableQuitKeybindings)
if m.Help.ShowAll {
m.KeyMap.ShowFullHelp.SetEnabled(true)
@@ -687,7 +741,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, tea.Quit
}
case filterMatchesMsg:
case FilterMatchesMsg:
m.filteredItems = filteredItems(msg)
return m, nil
@@ -999,7 +1053,10 @@ func (m Model) titleView() string {
}
}
return titleBarStyle.Render(view)
if len(view) > 0 {
return titleBarStyle.Render(view)
}
return view
}
func (m Model) statusView() string {
@@ -1008,21 +1065,25 @@ func (m Model) statusView() string {
totalItems := len(m.items)
visibleItems := len(m.VisibleItems())
plural := ""
var itemName string
if visibleItems != 1 {
plural = "s"
itemName = m.itemNamePlural
} else {
itemName = m.itemNameSingular
}
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
if m.filterState == Filtering {
// Filter results
if visibleItems == 0 {
status = m.Styles.StatusEmpty.Render("Nothing matched")
} else {
status = fmt.Sprintf("%d item%s", visibleItems, plural)
status = itemsDisplay
}
} else if len(m.items) == 0 {
// Not filtering: no items.
status = m.Styles.StatusEmpty.Render("No items")
status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
} else {
// Normal
filtered := m.FilterState() == FilterApplied
@@ -1033,7 +1094,7 @@ func (m Model) statusView() string {
status += fmt.Sprintf("“%s” ", f)
}
status += fmt.Sprintf("%d item%s", visibleItems, plural)
status += itemsDisplay
}
numFiltered := totalItems - visibleItems
@@ -1077,7 +1138,7 @@ func (m Model) populatedView() string {
if m.filterState == Filtering {
return ""
}
m.Styles.NoItems.Render("No items found.")
return m.Styles.NoItems.Render("No " + m.itemNamePlural + " found.")
}
if len(items) > 0 {
@@ -1118,7 +1179,7 @@ func (m Model) spinnerView() string {
func filterItems(m Model) tea.Cmd {
return func() tea.Msg {
if m.FilterInput.Value() == "" || m.filterState == Unfiltered {
return filterMatchesMsg(m.itemsAsFilterItems()) // return nothing
return FilterMatchesMsg(m.itemsAsFilterItems()) // return nothing
}
targets := []string{}
@@ -1128,18 +1189,15 @@ func filterItems(m Model) tea.Cmd {
targets = append(targets, t.FilterValue())
}
var ranks fuzzy.Matches = fuzzy.Find(m.FilterInput.Value(), targets)
sort.Stable(ranks)
filterMatches := []filteredItem{}
for _, r := range ranks {
for _, r := range m.Filter(m.FilterInput.Value(), targets) {
filterMatches = append(filterMatches, filteredItem{
item: items[r.Index],
matches: r.MatchedIndexes,
})
}
return filterMatchesMsg(filterMatches)
return FilterMatchesMsg(filterMatches)
}
}

74
list/list_test.go Normal file
View File

@@ -0,0 +1,74 @@
package list
import (
"fmt"
"io"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
type item string
func (i item) FilterValue() string { return "" }
type itemDelegate struct{}
func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { return nil }
func (d itemDelegate) Render(w io.Writer, m Model, index int, listItem Item) {
i, ok := listItem.(item)
if !ok {
return
}
str := fmt.Sprintf("%d. %s", index+1, i)
fmt.Fprint(w, m.Styles.TitleBar.Render(str))
}
func TestStatusBarItemName(t *testing.T) {
list := New([]Item{item("foo"), item("bar")}, itemDelegate{}, 10, 10)
expected := "2 items"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
list.SetItems([]Item{item("foo")})
expected = "1 item"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
}
func TestStatusBarWithoutItems(t *testing.T) {
list := New([]Item{}, itemDelegate{}, 10, 10)
expected := "No items"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
}
func TestCustomStatusBarItemName(t *testing.T) {
list := New([]Item{item("foo"), item("bar")}, itemDelegate{}, 10, 10)
list.SetStatusBarItemName("connection", "connections")
expected := "2 connections"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
list.SetItems([]Item{item("foo")})
expected = "1 connection"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
list.SetItems([]Item{})
expected = "No connections"
if !strings.Contains(list.statusView(), expected) {
t.Fatalf("Error: expected view to contain %s", expected)
}
}

View File

@@ -96,8 +96,8 @@ func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1
}
// NewModel creates a new model with defaults.
func NewModel() Model {
// New creates a new model with defaults.
func New() Model {
return Model{
Type: Arabic,
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.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {

View File

@@ -37,8 +37,6 @@ const (
defaultDamping = 1.0
)
var color func(string) termenv.Color = termenv.ColorProfile().Color
// Option is used to set options in NewModel. For example:
//
// progress := NewModel(
@@ -98,6 +96,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 {
return func(m *Model) {
m.SetSpringOptions(frequency, damping)
@@ -105,6 +108,13 @@ func WithSpringOptions(frequency, damping float64) Option {
}
}
// WithColorProfile sets the color profile to use for the progress bar.
func WithColorProfile(p termenv.Profile) Option {
return func(m *Model) {
m.colorProfile = p
}
}
// FrameMsg indicates that an animation step should occur.
type FrameMsg struct {
id int
@@ -139,8 +149,8 @@ type Model struct {
// Members for animated transitions.
spring harmonica.Spring
springCustomized bool
percent float64
targetPercent float64
percentShown float64 // percent currently displaying
targetPercent float64 // percent to which we're animating
velocity float64
// Gradient settings
@@ -152,10 +162,13 @@ type Model struct {
// of the progress bar. When false, the width of the gradient will be set
// to the full width of the progress bar.
scaleRamp bool
// Color profile for the progress bar.
colorProfile termenv.Profile
}
// NewModel returns a model with default values.
func NewModel(opts ...Option) Model {
// New returns a model with default values.
func New(opts ...Option) Model {
m := Model{
id: nextID(),
Width: defaultWidth,
@@ -165,6 +178,7 @@ func NewModel(opts ...Option) Model {
EmptyColor: "#606060",
ShowPercentage: true,
PercentFormat: " %3.0f%%",
colorProfile: termenv.ColorProfile(),
}
if !m.springCustomized {
m.SetSpringOptions(defaultFrequency, defaultDamping)
@@ -176,6 +190,11 @@ func NewModel(opts ...Option) Model {
return m
}
// NewModel returns a model with default values.
//
// Deprecated. Use New instead.
var NewModel = New
// Init exists satisfy the tea.Model interface.
func (m Model) Init() tea.Cmd {
return nil
@@ -193,12 +212,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 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 {
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()
default:
@@ -208,12 +227,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// SetSpringOptions sets the frequency and damping for the current spring.
// Frequency corresponds to speed, and damping to bounciness. For details see:
// https://github.com/charmbracelet/harmonica.
//
// https://github.com/charmbracelet/harmonica
func (m *Model) SetSpringOptions(frequency, damping float64) {
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
}
// Percent returns the current percentage state of the model. This is only
// Percent returns the current visible percentage on the model. This is only
// relevant when you're animating the progress bar.
//
// If you're rendering with ViewAs you won't need this.
@@ -250,7 +270,7 @@ func (m *Model) DecrPercent(v float64) tea.Cmd {
// View renders the an animated progress bar in its current state. To render
// a static progress bar based on your own calculations use ViewAs instead.
func (m Model) View() string {
return m.ViewAs(m.percent)
return m.ViewAs(m.percentShown)
}
// ViewAs renders the progress bar with a given percentage.
@@ -288,18 +308,18 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
b.WriteString(termenv.
String(string(m.Full)).
Foreground(color(c)).
Foreground(m.color(c)).
String(),
)
}
} else {
// Solid fill
s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String()
s := termenv.String(string(m.Full)).Foreground(m.color(m.FullColor)).String()
b.WriteString(strings.Repeat(s, fw))
}
// Empty fill
e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String()
e := termenv.String(string(m.Empty)).Foreground(m.color(m.EmptyColor)).String()
n := max(0, tw-fw)
b.WriteString(strings.Repeat(e, n))
}
@@ -327,6 +347,10 @@ func (m *Model) setRamp(colorA, colorB string, scaled bool) {
m.rampColorB = b
}
func (m Model) color(c string) termenv.Color {
return m.colorProfile.Color(c)
}
func max(a, b int) int {
if a > b {
return a

View File

@@ -1,14 +1,28 @@
package spinner
import (
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"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.
type Spinner struct {
Frames []string
@@ -58,7 +72,6 @@ var (
// Model contains the state for the spinner. Use NewModel to create new models
// rather than using Model as a struct literal.
type Model struct {
// Spinner settings to use. See type Spinner.
Spinner Spinner
@@ -69,125 +82,46 @@ type Model struct {
// https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
// MinimumLifetime is the minimum amount of time the spinner can run. Any
// logic around this can be implemented in view that implements this
// 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
startTime time.Time
tag int
frame int
id int
tag int
}
// Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Start() {
m.startTime = time.Now()
// ID returns the spinner's unique ID.
func (m Model) ID() int {
return m.id
}
// 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{}
// New returns a model with default values.
func New() Model {
return Model{
Spinner: Line,
id: nextID(),
}
}
// 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.
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.
type TickMsg struct {
Time time.Time
tag int
ID int
}
// Update is the Tea update function. This will advance the spinner one frame
// every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly.
// Update is the Tea update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
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.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
@@ -201,7 +135,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
m.tag++
return m, m.tick(m.tag)
return m, m.tick(m.id, m.tag)
default:
return m, nil
}
@@ -213,29 +147,39 @@ func (m Model) View() string {
return "(error)"
}
frame := 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)
return m.Style.Render(m.Spinner.Frames[m.frame])
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
func (m Model) Tick() tea.Msg {
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 TickMsg{
Time: t,
ID: id,
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.
EchoNone
// EchoOnEdit
// EchoOnEdit.
)
// blinkCtx manages cursor blinking.
@@ -91,6 +91,9 @@ func (c CursorMode) String() string {
}[c]
}
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
@@ -150,10 +153,16 @@ type Model struct {
// cursorMode determines the behavior of the cursor
cursorMode CursorMode
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
// input is considered valid.
Validate ValidateFunc
}
// NewModel creates a new model with default settings.
func NewModel() Model {
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
BlinkSpeed: defaultBlinkSpeed,
@@ -174,15 +183,29 @@ 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.
func (m *Model) SetValue(s string) {
if m.Validate != nil {
if err := m.Validate(s); err != nil {
m.Err = err
return
}
}
m.Err = nil
runes := []rune(s)
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = runes
}
if m.pos == 0 || m.pos > len(m.value) {
if (m.pos == 0 && len(m.value) == 0) || m.pos > len(m.value) {
m.setCursor(len(m.value))
}
m.handleOverflow()
@@ -198,6 +221,11 @@ func (m Model) Cursor() int {
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
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) {
@@ -229,7 +257,7 @@ func (m *Model) cursorStart() bool {
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() {
m.cursorEnd()
}
@@ -240,7 +268,7 @@ func (m Model) CursorMode() CursorMode {
return m.cursorMode
}
// CursorMode sets the model's cursor mode. This method returns a command.
// SetCursorMode 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 {
@@ -316,6 +344,8 @@ func (m *Model) handlePaste(v string) bool {
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
oldPos := m.pos
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
@@ -329,7 +359,12 @@ func (m *Model) handlePaste(v string) bool {
}
// Put it all back together
m.value = append(head, tail...)
value := append(head, tail...)
m.SetValue(string(value))
if m.Err != nil {
m.pos = oldPos
}
// Reset blink state if necessary and run overflow checks
return m.setCursor(m.pos)
@@ -407,9 +442,16 @@ func (m *Model) deleteWordLeft() bool {
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)
for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor
blink = m.setCursor(m.pos - 1)
}
@@ -426,10 +468,10 @@ func (m *Model) deleteWordLeft() bool {
}
}
if i > len(m.value) {
if oldPos > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[i:]...)
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
}
return blink
@@ -447,11 +489,15 @@ func (m *Model) deleteWordRight() bool {
return m.deleteAfterCursor()
}
i := m.pos
oldPos := m.pos
m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.setCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
}
}
for m.pos < len(m.value) {
@@ -463,12 +509,12 @@ func (m *Model) deleteWordRight() bool {
}
if m.pos > len(m.value) {
m.value = m.value[:i]
m.value = m.value[:oldPos]
} 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
@@ -566,6 +612,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyBackspace: // delete character before cursor
m.Err = nil
if msg.Alt {
resetBlink = m.deleteWordLeft()
} else {
@@ -608,7 +656,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
resetBlink = m.deleteBeforeCursor()
case tea.KeyCtrlV: // ^V paste
return m, Paste
case tea.KeyRunes: // input regular characters
case tea.KeyRunes, tea.KeySpace: // input regular characters
if msg.Alt && len(msg.Runes) == 1 {
if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor
resetBlink = m.deleteWordRight()
@@ -626,8 +674,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
resetBlink = m.setCursor(m.pos + len(msg.Runes))
runes := msg.Runes
value := make([]rune, len(m.value))
copy(value, m.value)
value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
m.SetValue(string(value))
if m.Err == nil {
resetBlink = m.setCursor(m.pos + len(runes))
}
}
}
@@ -784,6 +839,9 @@ func Paste() tea.Msg {
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
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}
}
}

48
viewport/keymap.go Normal file
View File

@@ -0,0 +1,48 @@
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"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
}
}

View File

@@ -4,26 +4,44 @@ import (
"math"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
spacebar = " "
mouseWheelDelta = 3
)
// New returns a new model with the given width and height as well as default
// keymappings.
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.
type Model struct {
Width 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 int
// 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
// 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
// provide higher performance rendering. Most of the time the normal Bubble
// 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.
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.
@@ -45,13 +76,13 @@ func (m Model) AtTop() bool {
// AtBottom returns whether or not the viewport is at or past the very bottom
// position.
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
// line. This can happen when adjusting the viewport height.
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.
@@ -69,7 +100,7 @@ func (m Model) ScrollPercent() float64 {
// SetContent set the pager's text content. For high performance rendering the
// Sync command should also be called.
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")
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) {
if len(m.lines) > 0 {
top := max(0, m.YOffset)
@@ -87,6 +125,21 @@ func (m Model) visibleLines() (lines []string) {
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.
// Basically, "page down".
func (m *Model) ViewDown() []string {
@@ -94,11 +147,7 @@ func (m *Model) ViewDown() []string {
return nil
}
m.YOffset = min(
m.YOffset+m.Height, // target
len(m.lines)-1-m.Height, // fallback
)
m.SetYOffset(m.YOffset + m.Height)
return m.visibleLines()
}
@@ -108,11 +157,7 @@ func (m *Model) ViewUp() []string {
return nil
}
m.YOffset = max(
m.YOffset-m.Height, // target
0, // fallback
)
m.SetYOffset(m.YOffset - m.Height)
return m.visibleLines()
}
@@ -122,18 +167,8 @@ func (m *Model) HalfViewDown() (lines []string) {
return nil
}
m.YOffset = min(
m.YOffset+m.Height/2, // target
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
m.SetYOffset(m.YOffset + m.Height/2)
return m.visibleLines()
}
// HalfViewUp moves the view up by half the height of the viewport.
@@ -142,18 +177,8 @@ func (m *Model) HalfViewUp() (lines []string) {
return nil
}
m.YOffset = max(
m.YOffset-m.Height/2, // target
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
m.SetYOffset(m.YOffset - m.Height/2)
return m.visibleLines()
}
// 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
// greater than the number of lines we actually have left before we reach
// the bottom.
maxDelta := (len(m.lines) - 1) - (m.YOffset + m.Height) // number of lines - viewport bottom edge
n = min(n, maxDelta)
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
m.SetYOffset(m.YOffset + n)
return m.visibleLines()
}
// 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
// greater than the number of lines we are from the top.
n = min(n, m.YOffset)
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
m.SetYOffset(m.YOffset - n)
return m.visibleLines()
}
// GotoTop sets the viewport to the top position.
@@ -210,32 +213,16 @@ func (m *Model) GotoTop() (lines []string) {
return nil
}
m.YOffset = 0
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
m.SetYOffset(0)
return m.visibleLines()
}
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0)
if len(m.lines) > 0 {
top := m.YOffset
bottom := max(len(m.lines)-1, 0)
lines = m.lines[top:bottom]
}
return lines
m.SetYOffset(m.maxYOffset())
return m.visibleLines()
}
// COMMANDS
// 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
// first render and after a window resize.
@@ -245,17 +232,8 @@ func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
// TODO: we should probably use m.visibleLines() rather than these two
// 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,
)
top, bottom := m.scrollArea()
return tea.SyncScrollArea(m.visibleLines(), top, bottom)
}
// 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 {
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
@@ -279,57 +258,60 @@ func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height)
top, bottom := m.scrollArea()
return tea.ScrollUp(lines, top, bottom)
}
// UPDATE
// 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.
// Update handles standard message-based viewport updates.
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
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
// Down one page
case "pgdown", spacebar, "f":
switch {
case key.Matches(msg, m.KeyMap.PageDown):
lines := m.ViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up one page
case "pgup", "b":
case key.Matches(msg, m.KeyMap.PageUp):
lines := m.ViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down half page
case "d", "ctrl+d":
case key.Matches(msg, m.KeyMap.HalfPageDown):
lines := m.HalfViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up half page
case "u", "ctrl+u":
case key.Matches(msg, m.KeyMap.HalfPageUp):
lines := m.HalfViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down one line
case "down", "j":
case key.Matches(msg, m.KeyMap.Down):
lines := m.LineDown(1)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up one line
case "up", "k":
case key.Matches(msg, m.KeyMap.Up):
lines := m.LineUp(1)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
@@ -337,15 +319,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
case tea.MouseMsg:
if !m.MouseWheelEnabled {
break
}
switch msg.Type {
case tea.MouseWheelUp:
lines := m.LineUp(mouseWheelDelta)
lines := m.LineUp(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(mouseWheelDelta)
lines := m.LineDown(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
@@ -355,13 +340,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd
}
// VIEW
// View renders the viewport into a string.
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're doing to be rendering the actual
// content seprately. We still need send something that equals the
// Just send newlines since we're going to be rendering the actual
// content seprately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", m.Height-1)
@@ -372,15 +355,19 @@ func (m Model) View() string {
// Fill empty space with newlines
extraLines := ""
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 {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}