17 Commits

Author SHA1 Message Date
Maas Lalani
8d709dc384 chore: bump go from 1.13 to 1.18 2022-07-07 10:56:47 -04:00
Hironao OTSUBO
d4eeb4f5be fix(key): unbound keys are not enabled 2022-07-06 17:01:28 -07:00
Jonah
b74f696cc8 feat(list): add IsFiltered method (#98) 2022-07-05 16:38:10 -04:00
Maas Lalani
3a34fc4ff8 fix(textarea): Add deleteWordRight 2022-07-05 14:26:22 -04:00
Maas Lalani
2a2fb10f5f fix(textarea): account for base styling frame when calculating width 2022-07-05 14:26:22 -04:00
Gabriel Nagy
588393fbf8 fix(textinput): set cursor on initial value
This reverts the code back to the logic from 4ce16e8 which fixed the
issue of the cursor not being moved when an initial value was set with
SetValue.

The fix regressed in 16053f4.
2022-07-05 09:16:19 -04:00
Leandro López
7cc5786984 feat(spinners): Construct new spinners with WithSpinner + WithStyle options (#148)
* Add spinner.New test

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.Option type and spinner.WithSpinner option

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Allow passing options in spinner.New

This doesn't break existing code as it uses variadic arguments, so
any existing code as the following should continue to work:

    s := spinner.New()
    s.spinner = spinner.Dot

This change allows for instead of those two lines, having a call:

   s := spinner.New(spinner.WithSpinner(spinner.Dot))

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.WithX option for each spinner.Spinner

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Refactor spinner tests

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.WithStyle option function

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* refactor: remove With... Spinner aliases

Co-authored-by: Maas Lalani <maas@lalani.dev>
2022-07-01 11:31:26 -04:00
Kyosuke Fujimoto
3899e1b4cd fix(list): Disable Quit Key (#104) 2022-07-01 11:00:34 -04:00
Pablo Díaz-López
4c1762413a fix(timer): stop should return cmd (#138) 2022-07-01 10:36:12 -04:00
Maas Lalani
2fd583c8ef feat(textarea): Add multi-line text input 2022-06-28 12:29:24 -04:00
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
13 changed files with 2078 additions and 38 deletions

View File

@@ -41,6 +41,18 @@ the common, and many customization options.
* [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go)
## Text Area
<img src="https://stuff.charm.sh/bubbles-examples/textarea.gif" width="400" alt="Text Area Example">
A text area field, akin to an `<textarea />` in HTML. Allows for input that
spans multiple lines. Supports unicode, pasting, vertical scrolling when the
value exceeds the width and height of the element, and many customization
options.
* [Example code, chat input](https://github.com/charmbracelet/tea/tree/master/examples/chat/main.go)
* [Example code, story time input](https://github.com/charmbracelet/tea/tree/master/examples/textarea/main.go)
## Progress
@@ -66,7 +78,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
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

207
cursor/cursor.go Normal file
View File

@@ -0,0 +1,207 @@
package cursor
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const defaultBlinkSpeed = time.Millisecond * 530
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// BlinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type BlinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// Mode describes the behavior of the cursor.
type Mode int
// Available cursor modes.
const (
CursorBlink Mode = iota
CursorStatic
CursorHide
)
// String returns the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c Mode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
TextStyle lipgloss.Style
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// cursorMode determines the behavior of the cursor
cursorMode Mode
}
// New creates a new model with default settings.
func New() Model {
return Model{
BlinkSpeed: defaultBlinkSpeed,
Blink: true,
cursorMode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
// Update updates the cursor.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case initialBlinkMsg:
// We accept all initialBlinkMsgs generated 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 blink-able?
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 {
m.Blink = !m.Blink
cmd = m.BlinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
}
return m, nil
}
// CursorMode returns the model's cursor mode. For available cursor modes, see
// type CursorMode.
func (m Model) CursorMode() Mode {
return m.cursorMode
}
// SetCursorMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode Mode) tea.Cmd {
m.cursorMode = mode
m.Blink = m.cursorMode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// BlinkCmd is an command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd {
if m.cursorMode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
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 blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return initialBlinkMsg{}
}
// Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd {
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
}
// Blur blurs the cursor.
func (m *Model) Blur() {
m.focus = false
m.Blink = true
}
// SetChar sets the character under the cursor.
func (m *Model) SetChar(char string) {
m.char = char
}
// View displays the cursor.
func (m Model) View() string {
if m.Blink {
return m.TextStyle.Render(m.char)
}
return m.Style.Inline(true).Reverse(true).Render(m.char)
}

18
go.mod
View File

@@ -1,16 +1,26 @@
module github.com/charmbracelet/bubbles
go 1.13
go 1.18
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.21.0
github.com/charmbracelet/bubbletea v0.22.0
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.5.0
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
github.com/muesli/termenv v0.12.0
github.com/sahilm/fuzzy v0.1.0
)
require (
github.com/containerd/console v1.0.3 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
)

14
go.sum
View File

@@ -1,7 +1,7 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
@@ -20,19 +20,17 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
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=

View File

@@ -106,7 +106,7 @@ func (b Binding) Help() Help {
// 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
return !b.disabled || b.keys == nil
}
// SetEnabled enables or disables the keybinding.

View File

@@ -87,7 +87,7 @@ type Rank struct {
// DefaultFilter uses the sahilm/fuzzy to filter through the list.
// This is set by default.
func DefaultFilter(term string, targets []string) []Rank {
var ranks fuzzy.Matches = fuzzy.Find(term, targets)
var ranks = fuzzy.Find(term, targets)
sort.Stable(ranks)
result := make([]Rank, len(ranks))
for i, r := range ranks {
@@ -129,6 +129,9 @@ type Model struct {
showHelp bool
filteringEnabled bool
itemNameSingular string
itemNamePlural string
Title string
Styles Styles
@@ -202,6 +205,8 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
showStatusBar: true,
showPagination: true,
showHelp: true,
itemNameSingular: "item",
itemNamePlural: "items",
filteringEnabled: true,
KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
@@ -286,7 +291,19 @@ func (m Model) ShowStatusBar() bool {
return m.showStatusBar
}
// ShowingPagination hides or shoes the paginator. Note that pagination will
// SetStatusBarItemName defines a replacement for the items identifier.
// Defaults to item/items.
func (m *Model) SetStatusBarItemName(singular, plural string) {
m.itemNameSingular = singular
m.itemNamePlural = plural
}
// StatusBarItemName returns singular and plural status bar item names.
func (m Model) StatusBarItemName() (string, string) {
return m.itemNameSingular, m.itemNamePlural
}
// SetShowPagination hides or shoes the paginator. Note that pagination will
// still be active, it simply won't be displayed.
func (m *Model) SetShowPagination(v bool) {
m.showPagination = v
@@ -517,6 +534,15 @@ func (m Model) SettingFilter() bool {
return m.filterState == Filtering
}
// IsFiltered returns whether or not the list is currently filtered.
// It's purely a convenience method for the following:
//
// m.FilterState() == FilterApplied
//
func (m Model) IsFiltered() bool {
return m.filterState == FilterApplied
}
// Width returns the current width setting.
func (m Model) Width() int {
return m.width
@@ -552,7 +578,7 @@ func (m *Model) StopSpinner() {
m.showSpinner = false
}
// Helper for disabling the keybindings used for quitting, incase you want to
// Helper for disabling the keybindings used for quitting, in case you want to
// handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() {
m.disableQuitKeybindings = true
@@ -1048,21 +1074,25 @@ func (m Model) statusView() string {
totalItems := len(m.items)
visibleItems := len(m.VisibleItems())
plural := ""
var itemName string
if visibleItems != 1 {
plural = "s"
itemName = m.itemNamePlural
} else {
itemName = m.itemNameSingular
}
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
if m.filterState == Filtering {
// Filter results
if visibleItems == 0 {
status = m.Styles.StatusEmpty.Render("Nothing matched")
} else {
status = fmt.Sprintf("%d item%s", visibleItems, plural)
status = itemsDisplay
}
} else if len(m.items) == 0 {
// Not filtering: no items.
status = m.Styles.StatusEmpty.Render("No items")
status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
} else {
// Normal
filtered := m.FilterState() == FilterApplied
@@ -1073,7 +1103,7 @@ func (m Model) statusView() string {
status += fmt.Sprintf("“%s” ", f)
}
status += fmt.Sprintf("%d item%s", visibleItems, plural)
status += itemsDisplay
}
numFiltered := totalItems - visibleItems
@@ -1117,7 +1147,7 @@ func (m Model) populatedView() string {
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 {

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

@@ -8,8 +8,8 @@ import (
"github.com/charmbracelet/lipgloss"
)
// Internal ID management for text inputs. Necessary for blink integrity when
// multiple text inputs are involved.
// Internal ID management. Used during animating to ensure that frame messages
// are received only by spinner components that sent them.
var (
lastID int
idMtx sync.Mutex
@@ -67,6 +67,22 @@ var (
Frames: []string{"🙈", "🙉", "🙊"},
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
@@ -93,11 +109,17 @@ func (m Model) ID() int {
}
// New returns a model with default values.
func New() Model {
return Model{
func New(opts ...Option) Model {
m := Model{
Spinner: Line,
id: nextID(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// NewModel returns a model with default values.
@@ -112,9 +134,7 @@ type TickMsg struct {
ID int
}
// Update is the Tea update function. This will advance the spinner one frame
// every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly.
// Update is the Tea update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case TickMsg:
@@ -185,3 +205,23 @@ func (m Model) tick(id, tag int) tea.Cmd {
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}
// Option is used to set options in New. For example:
//
// spinner := New(WithSpinner(Dot))
//
type Option func(*Model)
// WithSpinner is an option to set the spinner.
func WithSpinner(spinner Spinner) Option {
return func(m *Model) {
m.Spinner = spinner
}
}
// WithStyle is an option to set the spinner style.
func WithStyle(style lipgloss.Style) Option {
return func(m *Model) {
m.Style = style
}
}

61
spinner/spinner_test.go Normal file
View File

@@ -0,0 +1,61 @@
package spinner_test
import (
"testing"
"github.com/charmbracelet/bubbles/spinner"
)
func TestSpinnerNew(t *testing.T) {
assertEqualSpinner := func(t *testing.T, exp, got spinner.Spinner) {
t.Helper()
if exp.FPS != got.FPS {
t.Errorf("expecting %d FPS, got %d", exp.FPS, got.FPS)
}
if e, g := len(exp.Frames), len(got.Frames); e != g {
t.Fatalf("expecting %d frames, got %d", e, g)
}
for i, e := range exp.Frames {
if g := got.Frames[i]; e != g {
t.Errorf("expecting frame index %d with value %q, got %q", i, e, g)
}
}
}
t.Run("default", func(t *testing.T) {
s := spinner.New()
assertEqualSpinner(t, spinner.Line, s.Spinner)
})
t.Run("WithSpinner", func(t *testing.T) {
customSpinner := spinner.Spinner{
Frames: []string{"a", "b", "c", "d"},
FPS: 16,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
assertEqualSpinner(t, customSpinner, s.Spinner)
})
tests := map[string]spinner.Spinner{
"Line": spinner.Line,
"Dot": spinner.Dot,
"MiniDot": spinner.MiniDot,
"Jump": spinner.Jump,
"Pulse": spinner.Pulse,
"Points": spinner.Points,
"Globe": spinner.Globe,
"Moon": spinner.Moon,
"Monkey": spinner.Monkey,
}
for name, s := range tests {
t.Run(name, func(t *testing.T) {
assertEqualSpinner(t, spinner.New(spinner.WithSpinner(s)).Spinner, s)
})
}
}

1130
textarea/textarea.go Normal file

File diff suppressed because it is too large Load Diff

446
textarea/textarea_test.go Normal file
View File

@@ -0,0 +1,446 @@
package textarea
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestNew(t *testing.T) {
textarea := newTextArea()
view := textarea.View()
if !strings.Contains(view, ">") {
t.Log(view)
t.Error("Text area did not render the prompt")
}
if !strings.Contains(view, "World!") {
t.Log(view)
t.Error("Text area did not render the placeholder")
}
}
func TestInput(t *testing.T) {
textarea := newTextArea()
input := "foo"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
if !strings.Contains(view, input) {
t.Log(view)
t.Error("Text area did not render the input")
}
if textarea.col != len(input) {
t.Log(view)
t.Error("Text area did not move the cursor to the correct position")
}
}
func TestSoftWrap(t *testing.T) {
textarea := newTextArea()
textarea.Prompt = ""
textarea.ShowLineNumbers = false
textarea.SetWidth(5)
textarea.SetHeight(5)
textarea.CharLimit = 60
textarea, _ = textarea.Update(nil)
input := "foo bar baz"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
for _, word := range strings.Split(input, " ") {
if !strings.Contains(view, word) {
t.Log(view)
t.Error("Text area did not render the input")
}
}
// Due to the word wrapping, each word will be on a new line and the
// text area will look like this:
//
// > foo
// > bar
// > baz█
//
// However, due to soft-wrapping the column will still be at the end of the line.
if textarea.row != 0 || textarea.col != len(input) {
t.Log(view)
t.Error("Text area did not move the cursor to the correct position")
}
}
func TestCharLimit(t *testing.T) {
textarea := newTextArea()
// First input (foo bar) should be accepted as it will fall within the
// CharLimit. Second input (baz) should not appear in the input.
input := []string{"foo bar", "baz"}
textarea.CharLimit = len(input[0])
for _, k := range []rune(strings.Join(input, " ")) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
if strings.Contains(view, input[1]) {
t.Log(view)
t.Error("Text area should not include input past the character limit")
}
}
func TestVerticalScrolling(t *testing.T) {
textarea := newTextArea()
textarea.Prompt = ""
textarea.ShowLineNumbers = false
textarea.SetHeight(1)
textarea.SetWidth(20)
textarea.CharLimit = 100
textarea, _ = textarea.Update(nil)
input := "This is a really long line that should wrap around the text area."
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
// The view should contain the first "line" of the input.
if !strings.Contains(view, "This is a really") {
t.Log(view)
t.Error("Text area did not render the input")
}
// But we should be able to scroll to see the next line.
// Let's scroll down for each line to view the full input.
lines := []string{
"long line that",
"should wrap around",
"the text area.",
}
for _, line := range lines {
textarea.viewport.LineDown(1)
view = textarea.View()
if !strings.Contains(view, line) {
t.Log(view)
t.Error("Text area did not render the correct scrolled input")
}
}
}
func TestWordWrapOverflowing(t *testing.T) {
// An interesting edge case is when the user enters many words that fill up
// the text area and then goes back up and inserts a few words which causes
// a cascading wrap and causes an overflow of the last line.
//
// In this case, we should not let the user insert more words if, after the
// entire wrap is complete, the last line is overflowing.
textarea := newTextArea()
textarea.SetHeight(3)
textarea.SetWidth(20)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
// We have essentially filled the text area with input.
// Let's see if we can cause wrapping to overflow the last line.
textarea.row = 0
textarea.col = 0
input = "Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
lastLineWidth := textarea.LineInfo().Width
if lastLineWidth > 20 {
t.Log(lastLineWidth)
t.Log(textarea.View())
t.Fail()
}
}
func TestValueSoftWrap(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(16)
textarea.SetHeight(10)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Log(input)
t.Fatal("The text area does not have the correct value")
}
}
func TestSetValue(t *testing.T) {
textarea := newTextArea()
textarea.SetValue(strings.Join([]string{"Foo", "Bar", "Baz"}, "\n"))
if textarea.row != 2 && textarea.col != 3 {
t.Log(textarea.row, textarea.col)
t.Fatal("Cursor Should be on row 2 column 3 after inserting 2 new lines")
}
value := textarea.Value()
if value != "Foo\nBar\nBaz" {
t.Fatal("Value should be Foo\nBar\nBaz")
}
// SetValue should reset text area
textarea.SetValue("Test")
value = textarea.Value()
if value != "Test" {
t.Log(value)
t.Fatal("Text area was not reset when SetValue() was called")
}
}
func TestInsertString(t *testing.T) {
textarea := newTextArea()
// Insert some text
input := "foo baz"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
// Put cursor in the middle of the text
textarea.col = 4
textarea.InsertString("bar ")
value := textarea.Value()
if value != "foo bar baz" {
t.Log(value)
t.Fatal("Expected insert string to insert bar between foo and baz")
}
}
func TestCanHandleEmoji(t *testing.T) {
textarea := newTextArea()
input := "🧋"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
input = "🧋🧋🧋"
textarea.SetValue(input)
value = textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
if textarea.col != 3 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the third character")
}
if charOffset := textarea.LineInfo().CharOffset; charOffset != 6 {
t.Log(charOffset)
t.Fatal("Expected cursor to be on the sixth character")
}
}
func TestVerticalNavigationKeepsCursorHorizontalPosition(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(20)
textarea.SetValue(strings.Join([]string{"你好你好", "Hello"}, "\n"))
textarea.row = 0
textarea.col = 2
// 你好|你好
// Hell|o
// 1234|
// Let's imagine our cursor is on the first line where the pipe is.
// We press the down arrow to get to the next line.
// The issue is that if we keep the cursor on the same column, the cursor will jump to after the `e`.
//
// 你好|你好
// He|llo
//
// But this is wrong because visually we were at the 4th character due to
// the first line containing double-width runes.
// We want to keep the cursor on the same visual column.
//
// 你好|你好
// Hell|o
//
// This test ensures that the cursor is kept on the same visual column by
// ensuring that the column offset goes from 2 -> 4.
lineInfo := textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 2 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because there two double width runes on the first line.")
}
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(downMsg)
lineInfo = textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 4 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because we came down from the first line.")
}
}
func TestVerticalNavigationShouldRememberPositionWhileTraversing(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(40)
// Let's imagine we have a text area with the following content:
//
// Hello
// World
// This is a long line.
//
// If we are at the end of the last line and go up, we should be at the end
// of the second line.
// And, if we go up again we should be at the end of the first line.
// But, if we go back down twice, we should be at the end of the last line
// again and not the fifth (length of second line) character of the last line.
//
// In other words, we should remember the last horizontal position while
// traversing vertically.
textarea.SetValue(strings.Join([]string{"Hello", "World", "This is a long line."}, "\n"))
// We are at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Let's go up.
upMsg := tea.KeyMsg{Type: tea.KeyUp, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the second line.
if textarea.col != 5 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// And, again.
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the first line.
if textarea.col != 5 || textarea.row != 0 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the first line")
}
// Let's go down, twice.
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(downMsg)
textarea, _ = textarea.Update(downMsg)
// We should be at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Now, for correct behavior, if we move right or left, we should forget
// (reset) the saved horizontal position. Since we assume the user wants to
// keep the cursor where it is horizontally. This is how most text areas
// work.
textarea, _ = textarea.Update(upMsg)
leftMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(leftMsg)
if textarea.col != 4 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// Going down now should keep us at the 4th column since we moved left and
// reset the horizontal position saved state.
textarea, _ = textarea.Update(downMsg)
if textarea.col != 4 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 4th character of the last line")
}
}
func TestRendersEndOfLineBuffer(t *testing.T) {
textarea := newTextArea()
textarea.ShowLineNumbers = true
textarea.SetWidth(20)
view := textarea.View()
if !strings.Contains(view, "~") {
t.Log(view)
t.Fatal("Expected to see a tilde at the end of the line")
}
}
func newTextArea() Model {
textarea := New()
textarea.Prompt = "> "
textarea.Placeholder = "Hello, World!"
textarea.Focus()
textarea, _ = textarea.Update(nil)
return textarea
}
func keyPress(key rune) tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}, Alt: false}
}

View File

@@ -91,6 +91,9 @@ func (c CursorMode) String() string {
}[c]
}
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
@@ -150,9 +153,15 @@ type Model struct {
// cursorMode determines the behavior of the cursor
cursorMode CursorMode
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
// input is considered valid.
Validate ValidateFunc
}
// NewModel creates a new model with default settings.
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
@@ -181,6 +190,15 @@ var NewModel = New
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
if m.Validate != nil {
if err := m.Validate(s); err != nil {
m.Err = err
return
}
}
m.Err = nil
runes := []rune(s)
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
@@ -250,7 +268,7 @@ func (m Model) CursorMode() CursorMode {
return m.cursorMode
}
// CursorMode sets the model's cursor mode. This method returns a command.
// SetCursorMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
@@ -326,6 +344,8 @@ func (m *Model) handlePaste(v string) bool {
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
oldPos := m.pos
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
@@ -339,7 +359,12 @@ func (m *Model) handlePaste(v string) bool {
}
// Put it all back together
m.value = append(head, tail...)
value := append(head, tail...)
m.SetValue(string(value))
if m.Err != nil {
m.pos = oldPos
}
// Reset blink state if necessary and run overflow checks
return m.setCursor(m.pos)
@@ -587,6 +612,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyBackspace: // delete character before cursor
m.Err = nil
if msg.Alt {
resetBlink = m.deleteWordLeft()
} else {
@@ -647,8 +674,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
resetBlink = m.setCursor(m.pos + len(msg.Runes))
runes := msg.Runes
value := make([]rune, len(m.value))
copy(value, m.value)
value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
m.SetValue(string(value))
if m.Err == nil {
resetBlink = m.setCursor(m.pos + len(runes))
}
}
}

View File

@@ -162,9 +162,7 @@ func (m *Model) Start() tea.Cmd {
// Stop pauses the timer. Has no effect if the timer has timed out.
func (m *Model) Stop() tea.Cmd {
return func() tea.Msg {
return m.startStop(false)
}
return m.startStop(false)
}
// Toggle stops the timer if it's running and starts it if it's stopped.