38 Commits

Author SHA1 Message Date
Charlie Roth
42f85b4a1b docs: fix paginator example link (#177) 2022-06-21 09:23:57 -04:00
vzvu3k6k
4d0a0ea9d8 docs(spinner): correct comment about internal ID (#171) 2022-06-21 09:17:48 -04:00
Weslei Juan Novaes Pereira
658a4febc7 feat: new Hamburger + Meter spinners (#172)
Co-authored-by: Maas Lalani <maas@lalani.dev>
2022-06-21 08:59:23 -04:00
Maas Lalani
93e464296e docs(list): fix linting errors 2022-06-16 18:47:55 -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
14 changed files with 356 additions and 202 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

@@ -66,7 +66,7 @@ Supports "dot-style" pagination (similar to what you might see on iOS) and
numeric page numbering, but you could also just use this component for the numeric page numbering, but you could also just use this component for the
logic and visualize pagination however you like. logic and visualize pagination however you like.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go) * [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/paginator/main.go)
## Viewport ## Viewport
@@ -149,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"),
), ),
} }
@@ -171,13 +171,28 @@ 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 <!-- in alphabetical order by author -->
prompts for cases like selection, text input, and confirmation. Each prompt
comes with sensible defaults, remappable keybindings, any many customization * [76creates/stickers](https://github.com/76creates/stickers): Responsive
options. 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 * [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).

8
go.mod
View File

@@ -4,13 +4,13 @@ go 1.13
require ( require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.19.3 github.com/charmbracelet/bubbletea v0.21.0
github.com/charmbracelet/harmonica v0.1.0 github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.4.0 github.com/charmbracelet/lipgloss v0.5.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
github.com/muesli/reflow v0.3.0 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 github.com/sahilm/fuzzy v0.1.0
) )

42
go.sum
View File

@@ -1,32 +1,33 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw= github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 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 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.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 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.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 h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 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.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.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
@@ -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/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 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-20201119102817-f84b799fce68/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/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/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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

@@ -164,11 +164,10 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
return "" 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 ( var (
// 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 out []string
totalWidth int totalWidth int

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"),
// ), // ),
// } // }
// //

View File

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

View File

@@ -71,6 +71,34 @@ func (f filteredItems) items() []Item {
// message should be routed to Update for processing. // message should be routed to Update for processing.
type FilterMatchesMsg []filteredItem 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
}
// 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{} type statusMessageTimeoutMsg struct{}
// FilterState describes the current filtering state on the model. // FilterState describes the current filtering state on the model.
@@ -101,12 +129,20 @@ type Model struct {
showHelp bool showHelp bool
filteringEnabled bool filteringEnabled bool
itemNameSingular string
itemNamePlural string
Title string Title string
Styles Styles Styles Styles
// 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
@@ -169,8 +205,11 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
showStatusBar: true, showStatusBar: true,
showPagination: true, showPagination: true,
showHelp: true, showHelp: true,
itemNameSingular: "item",
itemNamePlural: "items",
filteringEnabled: true, filteringEnabled: true,
KeyMap: DefaultKeyMap(), KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
Styles: styles, Styles: styles,
Title: "List", Title: "List",
FilterInput: filterInput, FilterInput: filterInput,
@@ -252,7 +291,19 @@ func (m Model) ShowStatusBar() bool {
return m.showStatusBar 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. // still be active, it simply won't be displayed.
func (m *Model) SetShowPagination(v bool) { func (m *Model) SetShowPagination(v bool) {
m.showPagination = v m.showPagination = v
@@ -324,7 +375,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)
@@ -517,9 +569,10 @@ func (m *Model) StopSpinner() {
m.showSpinner = false 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. // 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)
} }
@@ -602,7 +655,7 @@ 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)
@@ -622,7 +675,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)
@@ -1000,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 { func (m Model) statusView() string {
@@ -1009,21 +1065,25 @@ func (m Model) statusView() string {
totalItems := len(m.items) totalItems := len(m.items)
visibleItems := len(m.VisibleItems()) visibleItems := len(m.VisibleItems())
plural := "" var itemName string
if visibleItems != 1 { if visibleItems != 1 {
plural = "s" itemName = m.itemNamePlural
} else {
itemName = m.itemNameSingular
} }
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
if m.filterState == Filtering { if m.filterState == Filtering {
// Filter results // Filter results
if visibleItems == 0 { if visibleItems == 0 {
status = m.Styles.StatusEmpty.Render("Nothing matched") status = m.Styles.StatusEmpty.Render("Nothing matched")
} else { } else {
status = fmt.Sprintf("%d item%s", visibleItems, plural) status = itemsDisplay
} }
} else if len(m.items) == 0 { } else if len(m.items) == 0 {
// Not filtering: no items. // Not filtering: no items.
status = m.Styles.StatusEmpty.Render("No items") status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
} else { } else {
// Normal // Normal
filtered := m.FilterState() == FilterApplied filtered := m.FilterState() == FilterApplied
@@ -1034,7 +1094,7 @@ func (m Model) statusView() string {
status += fmt.Sprintf("“%s” ", f) status += fmt.Sprintf("“%s” ", f)
} }
status += fmt.Sprintf("%d item%s", visibleItems, plural) status += itemsDisplay
} }
numFiltered := totalItems - visibleItems numFiltered := totalItems - visibleItems
@@ -1078,7 +1138,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 " + m.itemNamePlural + " found.")
} }
if len(items) > 0 { if len(items) > 0 {
@@ -1129,11 +1189,8 @@ 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,

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

@@ -37,8 +37,6 @@ const (
defaultDamping = 1.0 defaultDamping = 1.0
) )
var color func(string) termenv.Color = termenv.ColorProfile().Color
// Option is used to set options in NewModel. For example: // Option is used to set options in NewModel. For example:
// //
// progress := NewModel( // progress := NewModel(
@@ -110,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. // FrameMsg indicates that an animation step should occur.
type FrameMsg struct { type FrameMsg struct {
id int id int
@@ -144,8 +149,8 @@ type Model struct {
// Members for animated transitions. // Members 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
// Gradient settings // Gradient settings
@@ -157,6 +162,9 @@ type Model struct {
// of the progress bar. When false, the width of the gradient will be set // of the progress bar. When false, the width of the gradient will be set
// to the full width of the progress bar. // to the full width of the progress bar.
scaleRamp bool scaleRamp bool
// Color profile for the progress bar.
colorProfile termenv.Profile
} }
// New returns a model with default values. // New returns a model with default values.
@@ -170,6 +178,7 @@ func New(opts ...Option) Model {
EmptyColor: "#606060", EmptyColor: "#606060",
ShowPercentage: true, ShowPercentage: true,
PercentFormat: " %3.0f%%", PercentFormat: " %3.0f%%",
colorProfile: termenv.ColorProfile(),
} }
if !m.springCustomized { if !m.springCustomized {
m.SetSpringOptions(defaultFrequency, defaultDamping) m.SetSpringOptions(defaultFrequency, defaultDamping)
@@ -203,12 +212,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:
@@ -224,7 +233,7 @@ 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.
@@ -261,7 +270,7 @@ func (m *Model) DecrPercent(v float64) tea.Cmd {
// 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.
@@ -299,18 +308,18 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex() c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
b.WriteString(termenv. b.WriteString(termenv.
String(string(m.Full)). String(string(m.Full)).
Foreground(color(c)). Foreground(m.color(c)).
String(), String(),
) )
} }
} else { } else {
// Solid fill // 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)) b.WriteString(strings.Repeat(s, fw))
} }
// Empty fill // 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) n := max(0, tw-fw)
b.WriteString(strings.Repeat(e, n)) b.WriteString(strings.Repeat(e, n))
} }
@@ -338,6 +347,10 @@ func (m *Model) setRamp(colorA, colorB string, scaled bool) {
m.rampColorB = b m.rampColorB = b
} }
func (m Model) color(c string) termenv.Color {
return m.colorProfile.Color(c)
}
func max(a, b int) int { func max(a, b int) int {
if a > b { if a > b {
return a return a

View File

@@ -1,17 +1,15 @@
package spinner package spinner
import ( import (
"strings"
"sync" "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 // Internal ID management. Used during animating to ensure that frame messages
// multiple text inputs are involved. // are received only by spinner components that sent them.
var ( var (
lastID int lastID int
idMtx sync.Mutex idMtx sync.Mutex
@@ -69,12 +67,27 @@ var (
Frames: []string{"🙈", "🙉", "🙊"}, Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:gomnd FPS: time.Second / 3, //nolint:gomnd
} }
Meter = Spinner{
Frames: []string{
"▱▱▱",
"▰▱▱",
"▰▰▱",
"▰▰▰",
"▰▰▱",
"▰▱▱",
"▱▱▱",
},
FPS: time.Second / 7, //nolint:gomnd
}
Hamburger = Spinner{
Frames: []string{"☱", "☲", "☴", "☲"},
FPS: time.Second / 3, //nolint:gomnd
}
) )
// Model contains the state for the spinner. Use NewModel to create new models // Model contains the state for the spinner. Use NewModel to create new models
// rather than using Model as a struct literal. // rather than using Model as a struct literal.
type Model struct { type Model struct {
// Spinner settings to use. See type Spinner. // Spinner settings to use. See type Spinner.
Spinner Spinner Spinner Spinner
@@ -85,65 +98,9 @@ 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 frame int
// logic around this can be implemented in view that implements this id int
// spinner. If HideFor is set MinimumLifetime will be added on top of tag int
// 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
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()
}
// 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{}
}
} }
// ID returns the spinner's unique ID. // ID returns the spinner's unique ID.
@@ -151,48 +108,6 @@ func (m Model) ID() int {
return m.id return m.id
} }
// 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()
}
// New returns a model with default values. // New returns a model with default values.
func New() Model { func New() Model {
return Model{ return Model{
@@ -213,9 +128,7 @@ type TickMsg struct {
ID int ID int
} }
// Update is the Tea update function. This will advance the spinner one frame // Update is the Tea update function.
// every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case TickMsg: case TickMsg:
@@ -250,16 +163,7 @@ 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

View File

@@ -91,6 +91,9 @@ func (c CursorMode) String() string {
}[c] }[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. // Model is the Bubble Tea model for this text input element.
type Model struct { type Model struct {
Err error Err error
@@ -150,9 +153,15 @@ type Model struct {
// cursorMode determines the behavior of the cursor // cursorMode determines the behavior of the cursor
cursorMode CursorMode 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. // New creates a new model with default settings.
func New() Model { func New() Model {
return Model{ return Model{
Prompt: "> ", Prompt: "> ",
@@ -181,13 +190,22 @@ 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) {
if m.Validate != nil {
if err := m.Validate(s); err != nil {
m.Err = err
return
}
}
m.Err = nil
runes := []rune(s) runes := []rune(s)
if m.CharLimit > 0 && len(runes) > m.CharLimit { if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit] m.value = runes[:m.CharLimit]
} else { } else {
m.value = runes 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.setCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
@@ -203,6 +221,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) {
@@ -245,7 +268,7 @@ func (m Model) CursorMode() CursorMode {
return m.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. // For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd { func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
@@ -321,6 +344,8 @@ func (m *Model) handlePaste(v string) bool {
tail := make([]rune, len(tailSrc)) tail := make([]rune, len(tailSrc))
copy(tail, tailSrc) copy(tail, tailSrc)
oldPos := m.pos
// Insert pasted runes // Insert pasted runes
for _, r := range paste { for _, r := range paste {
head = append(head, r) head = append(head, r)
@@ -334,7 +359,12 @@ func (m *Model) handlePaste(v string) bool {
} }
// Put it all back together // 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 // Reset blink state if necessary and run overflow checks
return m.setCursor(m.pos) return m.setCursor(m.pos)
@@ -412,6 +442,11 @@ func (m *Model) deleteWordLeft() bool {
return m.deleteBeforeCursor() return m.deleteBeforeCursor()
} }
// 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 { if m.pos <= 0 {
@@ -433,10 +468,10 @@ func (m *Model) deleteWordLeft() bool {
} }
} }
if m.pos > 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[m.pos:]...) m.value = append(m.value[:m.pos], m.value[oldPos:]...)
} }
return blink return blink
@@ -454,7 +489,7 @@ 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
@@ -474,12 +509,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
@@ -577,6 +612,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.Type { switch msg.Type {
case tea.KeyBackspace: // delete character before cursor case tea.KeyBackspace: // delete character before cursor
m.Err = nil
if msg.Alt { if msg.Alt {
resetBlink = m.deleteWordLeft() resetBlink = m.deleteWordLeft()
} else { } else {
@@ -619,7 +656,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
resetBlink = m.deleteBeforeCursor() resetBlink = m.deleteBeforeCursor()
case tea.KeyCtrlV: // ^V paste case tea.KeyCtrlV: // ^V paste
return m, Paste return m, Paste
case tea.KeyRunes: // input regular characters case tea.KeyRunes, tea.KeySpace: // input regular characters
if msg.Alt && len(msg.Runes) == 1 { if msg.Alt && len(msg.Runes) == 1 {
if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor
resetBlink = m.deleteWordRight() resetBlink = m.deleteWordRight()
@@ -637,8 +674,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Input a regular character // Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit { if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...) runes := msg.Runes
resetBlink = m.setCursor(m.pos + len(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))
}
} }
} }
@@ -795,6 +839,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))
} }

View File

@@ -22,21 +22,27 @@ func DefaultKeyMap() KeyMap {
return KeyMap{ return KeyMap{
PageDown: key.NewBinding( PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"), key.WithKeys("pgdown", spacebar, "f"),
key.WithHelp("f/pgdn", "page down"),
), ),
PageUp: key.NewBinding( PageUp: key.NewBinding(
key.WithKeys("pgup", "b"), key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
), ),
HalfPageUp: key.NewBinding( HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"), key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
), ),
HalfPageDown: key.NewBinding( HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"), key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
), ),
Up: key.NewBinding( Up: key.NewBinding(
key.WithKeys("up", "k"), key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
), ),
Down: key.NewBinding( Down: key.NewBinding(
key.WithKeys("down", "j"), key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
), ),
} }
} }

View File

@@ -358,7 +358,10 @@ func (m Model) View() string {
extraLines = strings.Repeat("\n", max(0, m.Height-len(lines))) extraLines = strings.Repeat("\n", max(0, m.Height-len(lines)))
} }
return m.Style.Render(strings.Join(lines, "\n") + extraLines) return m.Style.Copy().
UnsetWidth().
UnsetHeight().
Render(strings.Join(lines, "\n") + extraLines)
} }
func clamp(v, low, high int) int { func clamp(v, low, high int) int {