1 Commits

Author SHA1 Message Date
Christian Muehlhaeuser
34cd93d6b5 Add Button component 2020-10-30 08:31:16 +01:00
17 changed files with 363 additions and 2879 deletions

View File

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

View File

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

118
README.md
View File

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

90
button/button.go Normal file
View File

@@ -0,0 +1,90 @@
package button
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/muesli/termenv"
)
var (
// color is a helper for returning colors.
color func(s string) termenv.Color = termenv.ColorProfile().Color
)
// Model is the Bubble Tea model for a button element.
type Model struct {
Err error
Label string
Default bool
TextColor string
BackgroundColor string
FocusedTextColor string
FocusedBackgroundColor string
// Focus indicates whether user focus should be on this button component
focus bool
}
// NewModel creates a new model with default settings.
func NewModel() Model {
return Model{
Label: "Button",
}
}
// Update is the Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// TODO: implement
}
}
return m, nil
}
// View renders the button in its current state.
func (m Model) View() string {
margin := m.styled(" ").String()
label := m.styled(m.Label)
if m.Default {
label = label.Underline()
}
return margin + label.String() + margin
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model.
func (m *Model) Focus() {
m.focus = true
}
// Blur removes the focus state on the model.
func (m *Model) Blur() {
m.focus = false
}
func (m Model) styled(s string) termenv.Style {
view := termenv.String(s)
if m.focus {
view = view.Foreground(color(m.FocusedTextColor)).
Background(color(m.FocusedBackgroundColor))
} else {
view = view.Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor))
}
return view
}

14
go.mod
View File

@@ -4,13 +4,9 @@ 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/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/sahilm/fuzzy v0.1.0
github.com/charmbracelet/bubbletea v0.12.1
github.com/mattn/go-runewidth v0.0.9
github.com/muesli/termenv v0.7.4
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect
)

61
go.sum
View File

@@ -1,44 +1,35 @@
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/charmbracelet/bubbletea v0.12.1 h1:t21pkG2IDBRduPbt2J64Dx5yt8yIidAkXwhhrc11SzY=
github.com/charmbracelet/bubbletea v0.12.1/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
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/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/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-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
// Package paginator provides a Bubble Tea package for calulating pagination
// package paginator provides a Bubble Tea package for calulating pagination
// and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status.
@@ -19,7 +19,7 @@ const (
Dots
)
// Model is the Bubble Tea model for this user interface.
// Model is the Tea model for this user interface.
type Model struct {
Type Type
Page int
@@ -35,7 +35,7 @@ type Model struct {
UseJKKeys bool
}
// SetTotalPages is a helper function for calculating the total number of pages
// SetTotalPages is a helper function for calculatng the total number of pages
// from a given number of items. It's use is optional since this pager can be
// used for other things beyond navigating sets. Note that it both returns the
// number of total pages and alters the model.
@@ -91,7 +91,7 @@ func (m *Model) NextPage() {
}
}
// OnLastPage returns whether or not we're on the last page.
// LastPage returns whether or not we're on the last page.
func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1
}
@@ -115,7 +115,7 @@ func NewModel() Model {
}
// Update is the Tea update function which binds keystrokes to pagination.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.UsePgUpPgDownKeys {
@@ -164,16 +164,16 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
// View renders the pagination to a string.
func (m Model) View() string {
func View(m Model) string {
switch m.Type {
case Dots:
return m.dotsView()
return dotsView(m)
default:
return m.arabicView()
return arabicView(m)
}
}
func (m Model) dotsView() string {
func dotsView(m Model) string {
var s string
for i := 0; i < m.TotalPages; i++ {
if i == m.Page {
@@ -185,7 +185,7 @@ func (m Model) dotsView() string {
return s
}
func (m Model) arabicView() string {
func arabicView(m Model) string {
return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages)
}

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,11 @@ import (
)
const (
spacebar = " "
mouseWheelDelta = 3
spacebar = " "
)
// Model is the Bubble Tea model for this viewport element.
// MODEL
type Model struct {
Width int
Height int
@@ -54,7 +54,7 @@ func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height
}
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
// Scrollpercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
@@ -71,10 +71,6 @@ func (m Model) ScrollPercent() float64 {
func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n")
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
}
// Return the lines that should currently be visible in the viewport.
@@ -221,7 +217,7 @@ func (m *Model) GotoTop() (lines []string) {
return lines
}
// GotoBottom sets the viewport to the bottom position.
// GotoTop sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0)
@@ -273,7 +269,7 @@ func ViewDown(m Model, lines []string) tea.Cmd {
}
// ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewUp to get the lines that should be
// number of lines height. Use Model.ViewDown to get the lines that should be
// rendered.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
@@ -287,7 +283,7 @@ func ViewUp(m Model, lines []string) tea.Cmd {
// Update runs the update loop with default keybindings similar to popular
// pagers. To define your own keybindings use the methods on Model (i.e.
// Model.LineDown()) and define your own update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
@@ -339,13 +335,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
lines := m.LineUp(mouseWheelDelta)
lines := m.LineUp(3)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(mouseWheelDelta)
lines := m.LineDown(3)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
@@ -358,7 +354,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// VIEW
// View renders the viewport into a string.
func (m Model) View() string {
func View(m Model) 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