34 Commits
v0.12.0 ... vim

Author SHA1 Message Date
Maas Lalani
9eea57c1e8 feat: insert new lines with o/O 2022-08-15 10:38:16 -04:00
Maas Lalani
e691e35a6d fix(textarea): reset lastCharOffset if not moving vertically 2022-08-15 10:38:15 -04:00
Maas Lalani
0f1a912f12 fix: edge cases with {d,c}iw and {d,c}aw 2022-08-15 10:38:15 -04:00
Maas Lalani
715108b08b fix(textarea): better SeekInner and SeekAround 2022-08-15 10:38:15 -04:00
Maas Lalani
3e1a14200c fix(textarea): ActionChange + e should also delete end of word 2022-08-15 10:38:15 -04:00
Maas Lalani
907f5e659d feat(textarea): motions for seeking to characters (f/t/T/F/a/i) 2022-08-15 10:38:15 -04:00
Maas Lalani
2cffee5f40 fix(textarea): deleteRange multiple lines 2022-08-15 10:38:15 -04:00
Maas Lalani
8c9798c7f6 feat(textarea): findWordRight / findWordLeft 2022-08-15 10:38:15 -04:00
Maas Lalani
fdfe776a07 feat: delete/change ranges 2022-08-15 10:38:14 -04:00
Maas Lalani
fb44abddf8 refactor: split files into {modal,normal,insert}.go 2022-08-15 10:38:14 -04:00
Maas Lalani
447ff2da6a feat: ranges and motions 2022-08-15 10:38:14 -04:00
Maas Lalani
57a01e62e3 fix: cursor updating
cursor remains static in Normal mode and blinks in insert mode.
2022-08-15 10:38:14 -04:00
Maas Lalani
f5b74d002c feat(textarea): basic vim emulation
Normal & Insert Mode
Normal Mode:
* i/I/a/A insert at cursor, after cursor, start, end
* e/E/w/W word forward
* b/B word backward
* h/j/k/l cursor left/down/up/right
* p to paste
2022-08-15 10:38:06 -04:00
kena
649f78e1fd fix(textarea): pre-allocation of value (#213)
The maximum slice size of m.value is the max number of rows,
which is the max height.
2022-08-15 10:25:20 -04:00
kena
e72e4a190b perf(textarea): amortize heap allocs in Value (#212) 2022-08-15 10:24:54 -04:00
kena
9b67fb8724 perf: avoid recomputing the key representation in key.Matches 2022-08-12 16:07:31 -04:00
Maas Lalani
93e3c756c4 docs: responsive images for mobile 2022-08-08 17:35:57 -04:00
Maas Lalani
e200bf8c81 feat: LineCount method for counting number of lines in textarea 2022-08-05 16:37:05 -04:00
Maas Lalani
47eee84147 fix: include new line characters for Length() 2022-08-05 16:37:05 -04:00
Maas Lalani
84eacf535a fix: CharLimit <= 0 should be ignored 2022-08-04 14:52:50 -04:00
Maas Lalani
8f65160828 fix: add ctrl+h to backspace text 2022-07-31 13:20:02 -04:00
Weslei Juan Novaes Pereira
58a9d4c7ec feat: table bubble (#173)
Add table Bubble for displaying tabular data

Co-authored-by: Maas Lalani <maas@lalani.dev>
Co-authored-by: Christian Rocha <christian@rocha.is>
2022-07-21 15:06:38 -04:00
Maas Lalani
e32dcde62a fix(viewport): Guard against negative string repetitions (#196) 2022-07-21 14:39:17 -04:00
Hironao OTSUBO
e49b5573bc fix(key): Unbound keys should be disabled
* fix condition for #188
This is a fix of https://github.com/charmbracelet/bubbles/pull/188
Unbound keys has nil keys and they are not Enabled
2022-07-11 10:45:18 -04:00
Maas Lalani
746ec595c3 fix(textarea): change placeholder color to match textinput (#192) 2022-07-07 22:34:48 -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
15 changed files with 3067 additions and 17 deletions

View File

@@ -41,10 +41,26 @@ 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
<img src="https://stuff.charm.sh/bubbles-examples/progress.gif" width="800" alt="Progressbar Example">
<picture>
<source media="(max-width: 800px)" srcset="https://stuff.charm.sh/bubbles-examples/progress.gif">
<source media="(min-width: 800px)" width="800" srcset="https://stuff.charm.sh/bubbles-examples/progress.gif">
<img src="https://stuff.charm.sh/bubbles-examples/progress.gif" alt="Progressbar Example">
</picture>
A simple, customizable progress meter, with optional animation via
[Harmonica][harmonica]. Supports solid and gradient fills. The empty and filled
@@ -71,7 +87,11 @@ logic and visualize pagination however you like.
## Viewport
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif?0" width="600" alt="Viewport Example">
<picture>
<source media="(max-width: 600px)" srcset="https://stuff.charm.sh/bubbles-examples/viewport.gif?0">
<source media="(min-width: 600px)" width="600" srcset="https://stuff.charm.sh/bubbles-examples/viewport.gif?0">
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif?0" alt="Viewport Example">
</picture>
A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available
@@ -87,7 +107,11 @@ indenting and text wrapping.
## List
<img src="https://stuff.charm.sh/bubbles-examples/list.gif" width="600" alt="List Example">
<picture>
<source media="(max-width: 600px)" srcset="https://stuff.charm.sh/bubbles-examples/list.gif">
<source media="(min-width: 600px)" width="600" srcset="https://stuff.charm.sh/bubbles-examples/list.gif">
<img src="https://stuff.charm.sh/bubbles-examples/list.gif" alt="List Example">
</picture>
A customizable, batteries-included component for browsing a set of items.
Features pagination, fuzzy filtering, auto-generated help, an activity spinner,
@@ -121,7 +145,11 @@ can be customized as you see fit.
## Help
<img src="https://stuff.charm.sh/bubbles-examples/help.gif" width="500" alt="Help Example">
<picture>
<source media="(max-width: 500px)" srcset="https://stuff.charm.sh/bubbles-examples/help.gif">
<source media="(min-width: 500px)" width="500" srcset="https://stuff.charm.sh/bubbles-examples/help.gif">
<img src="https://stuff.charm.sh/bubbles-examples/help.gif" alt="Help Example">
</picture>
A customizable horizontal mini help view that automatically generates itself
from your keybindings. It features single and multi-line modes, which the user

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)
}

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.
@@ -130,9 +130,10 @@ type Help struct {
// Matches checks if the given KeyMsg matches the given bindings.
func Matches(k tea.KeyMsg, b ...Binding) bool {
keys := k.String()
for _, binding := range b {
for _, v := range binding.keys {
if k.String() == v && binding.Enabled() {
if keys == v && binding.Enabled() {
return true
}
}

26
key/key_test.go Normal file
View File

@@ -0,0 +1,26 @@
package key
import (
"testing"
)
func TestBinding_Enabled(t *testing.T) {
binding := NewBinding(
WithKeys("k", "up"),
WithHelp("↑/k", "move up"),
)
if !binding.Enabled() {
t.Errorf("expected key to be Enabled")
}
binding.SetEnabled(false)
if binding.Enabled() {
t.Errorf("expected key not to be Enabled")
}
binding.SetEnabled(true)
binding.Unbind()
if binding.Enabled() {
t.Errorf("expected key not to be Enabled")
}
}

View File

@@ -534,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

View File

@@ -109,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.
@@ -199,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)
})
}
}

387
table/table.go Normal file
View File

@@ -0,0 +1,387 @@
package table
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// Model defines a state for the table widget.
type Model struct {
KeyMap KeyMap
cols []Column
rows []Row
cursor int
focus bool
styles Styles
viewport viewport.Model
}
// Row represents one line in the table.
type Row []string
// Column defines the table structure.
type Column struct {
Title string
Width int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgup", spacebar),
key.WithHelp("f", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
//
type Option func(*Model)
// New creates a new model for the table widget.
func New(opts ...Option) Model {
m := Model{
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateViewport()
return m
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, tea.Batch(cmds...)
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focusses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
for i := range m.rows {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
return m.rows[m.cursor]
}
// SetRows set a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// MoveUp moves the selection up by any number of row.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
m.UpdateViewport()
if m.cursor < m.viewport.YOffset {
m.viewport.SetYOffset(m.cursor)
}
}
// MoveDown moves the selection down by any number of row.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
if m.cursor > (m.viewport.YOffset + (m.viewport.Height - 1)) {
m.viewport.SetYOffset(m.cursor - (m.viewport.Height - 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
func (m *Model) FromValues(value, separator string) {
rows := []Row{}
for _, line := range strings.Split(value, "\n") {
r := Row{}
for _, field := range strings.Split(line, separator) {
r = append(r, field)
}
rows = append(rows, r)
}
m.SetRows(rows)
}
func (m Model) headersView() string {
var s = make([]string, len(m.cols))
for _, col := range m.cols {
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(rowID int) string {
var s = make([]string, len(m.cols))
for i, value := range m.rows[rowID] {
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if rowID == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
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
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

54
table/table_test.go Normal file
View File

@@ -0,0 +1,54 @@
package table
import "testing"
func TestFromValues(t *testing.T) {
input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, ",")
if len(table.rows) != 3 {
t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1", "bar1"},
{"foo2", "bar2"},
{"foo3", "bar3"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func TestFromValuesWithTabSeparator(t *testing.T) {
input := "foo1.\tbar1\nfoo,bar,baz\tbar,2"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, "\t")
if len(table.rows) != 2 {
t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1.", "bar1"},
{"foo,bar,baz", "bar,2"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func deepEqual(a, b []Row) bool {
if len(a) != len(b) {
return false
}
for i, r := range a {
for j, f := range r {
if f != b[i][j] {
return false
}
}
}
return true
}

791
textarea/modal.go Normal file
View File

@@ -0,0 +1,791 @@
package textarea
import (
"strconv"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/cursor"
tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth"
)
// Mode is the possible modes of the textarea for modal editing.
type Mode string
const (
// ModeNormal is the normal mode for navigating around text.
ModeNormal Mode = "normal"
// ModeInsert is the insert mode for inserting text.
ModeInsert Mode = "insert"
)
// SetMode sets the mode of the textarea.
func (m *Model) SetMode(mode Mode) tea.Cmd {
switch mode {
case ModeInsert:
m.mode = ModeInsert
return m.Cursor.SetCursorMode(cursor.CursorBlink)
case ModeNormal:
m.mode = ModeNormal
m.col = clamp(m.col-1, 0, len(m.value[m.row]))
return m.Cursor.SetCursorMode(cursor.CursorStatic)
}
return nil
}
// Action is the type of action that will be performed when the user completes
// a key combination.
type Action int
const (
// ActionMove moves the cursor.
ActionMove Action = iota
// ActionReplace replaces text.
ActionReplace
// ActionDelete deletes text.
ActionDelete
// ActionYank yanks text.
ActionYank
// ActionChange deletes text and enters insert mode.
ActionChange
)
// Position is a (row, column) pair representing a position of the cursor or
// any character.
type Position struct {
Row int
Col int
}
// Range is a range of characters in the text area.
type Range struct {
Start Position
End Position
}
// NormalCommand is a helper for keeping track of the various relevant information
// when performing vim motions in the textarea.
type NormalCommand struct {
// Buffer is the buffer of keys that have been press for the current
// command.
Buffer string
// Count is the number of times to replay the action. This is usually
// optional and defaults to 1.
Count int
// Action is the action to be performed.
Action Action
// Range is the range of characters to perform the action on.
Range Range
// Seeking is the type of seeking that is in progress.
Seeking SeekType
}
// SeekType are the possible types of seeking that can be in progress.
type SeekType int
const (
// SeekNone is the default seeking action.
SeekNone SeekType = iota
// SeekForwardTo is the seeking action for f.
SeekForwardTo // f
// SeekBackwardTo is the seeking action for F.
SeekBackwardTo // F
// SeekForwardUntil is the seeking action for t.
SeekForwardUntil // t
// SeekBackwardUntil is the seeking action for T.
SeekBackwardUntil // T
// SeekAround is the seeking action for a.
SeekAround // a
// SeekInner is the seeking action for i.
SeekInner // i
)
// IsSeeking returns whether the command is in the middle of seeking a range.
func (n *NormalCommand) IsSeeking() bool {
return n.Seeking != SeekNone
}
// IsSeekingForward returns whether the seeking action is in the forward direction (f, t).
func (n *NormalCommand) IsSeekingForward() bool {
return n.Seeking == SeekForwardTo || n.Seeking == SeekForwardUntil
}
// IsSeekingBackward returns whether the seeking action is in the backward direction (F, T).
func (n *NormalCommand) IsSeekingBackward() bool {
return n.Seeking == SeekBackwardTo || n.Seeking == SeekBackwardUntil
}
// executeMsg executes a command.
type executeMsg NormalCommand
// executeCmd implements tea.Cmd for an executeMsg.
func executeCmd(n NormalCommand) tea.Cmd {
return func() tea.Msg {
return executeMsg(n)
}
}
func (m *Model) insertUpdate(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
return m.SetMode(ModeNormal)
case "ctrl+k":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteAfterCursor()
case "ctrl+u":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteBeforeCursor()
case "backspace", "ctrl+h":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
if len(m.value[m.row]) > 0 {
m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
if m.col > 0 {
m.SetCursor(m.col - 1)
}
}
case "delete", "ctrl+d":
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][m.col+1:]...)
}
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
case "alt+backspace", "ctrl+w":
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteWordLeft()
case "alt+delete", "alt+d":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteWordRight()
case "enter", "ctrl+m":
if len(m.value) >= maxHeight {
return nil
}
m.col = clamp(m.col, 0, len(m.value[m.row]))
m.splitLine(m.row, m.col)
case "end", "ctrl+e":
m.CursorEnd()
case "home", "ctrl+a":
m.CursorStart()
case "right", "ctrl+f":
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
} else {
if m.row < len(m.value)-1 {
m.row++
m.CursorStart()
}
}
case "down", "ctrl+n":
m.CursorDown()
case "alt+right", "alt+f":
m.wordRight()
case "ctrl+v":
return Paste
case "left", "ctrl+b":
if m.col == 0 && m.row != 0 {
m.row--
m.CursorEnd()
break
}
if m.col > 0 {
m.SetCursor(m.col - 1)
}
case "up", "ctrl+p":
m.CursorUp()
case "alt+left", "alt+b":
m.wordLeft()
default:
if m.CharLimit > 0 && rw.StringWidth(m.Value()) >= m.CharLimit {
break
}
m.col = min(m.col, len(m.value[m.row]))
m.value[m.row] = append(m.value[m.row][:m.col], append(msg.Runes, m.value[m.row][m.col:]...)...)
m.SetCursor(m.col + len(msg.Runes))
}
case pasteMsg:
m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
}
return nil
}
func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.command.IsSeeking() {
return m.seekingUpdate(msg)
}
if m.command.Action == ActionReplace {
for i := m.col; i < m.col+max(m.command.Count, 1); i++ {
if i >= len(m.value[m.row]) || len(msg.Runes) <= 0 {
break
}
m.value[m.row][i] = msg.Runes[0]
}
m.command = &NormalCommand{}
break
}
if !strings.ContainsAny(msg.String(), "jk") {
m.lastCharOffset = 0
}
switch msg.String() {
case "esc":
m.command = &NormalCommand{}
return m.SetMode(ModeNormal)
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
if m.command.Count == 0 && msg.String() == "0" {
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: 0},
}
return executeCmd(*m.command)
}
v := m.command.Buffer + msg.String()
count, err := strconv.Atoi(v)
if err != nil {
count, _ = strconv.Atoi(msg.String())
m.command.Buffer = msg.String()
m.command.Count = count
} else {
m.command.Buffer = v
m.command.Count = count
}
case "o":
m.CursorEnd()
m.splitLine(m.row, m.col)
return m.SetMode(ModeInsert)
case "O":
m.CursorStart()
m.splitLine(m.row, m.col)
m.CursorUp()
return m.SetMode(ModeInsert)
case "G":
var row int
if m.command.Count > 0 {
row = m.command.Count - 1
} else {
row = len(m.value) - 1
}
m.command.Range = Range{
Start: Position{Row: row, Col: m.col},
End: Position{Row: clamp(row, 0, len(m.value)-1), Col: m.col},
}
return executeCmd(*m.command)
case "g":
if m.command.Buffer == "g" {
m.command.Range = Range{
Start: Position{Row: 0, Col: m.col},
End: Position{Row: 0, Col: clamp(m.col, 0, len(m.value[0]))},
}
return executeCmd(*m.command)
}
m.command.Buffer = "g"
case "x":
m.command.Action = ActionDelete
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col + max(m.command.Count, 1)},
}
return executeCmd(*m.command)
case "X":
m.command.Action = ActionDelete
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col - max(m.command.Count, 1)},
}
return executeCmd(*m.command)
case "c":
if m.command.Action == ActionChange {
m.CursorStart()
m.deleteAfterCursor()
m.command = &NormalCommand{}
return m.SetMode(ModeInsert)
}
m.command.Action = ActionChange
case "d":
if m.command.Action == ActionDelete {
for i := 0; i < max(m.command.Count, 1); i++ {
m.value[m.row] = []rune{}
if m.row < len(m.value)-1 {
m.mergeLineBelow(m.row)
} else {
m.mergeLineAbove(m.row)
}
}
m.command = &NormalCommand{}
return nil
}
m.command.Action = ActionDelete
case "y":
m.command.Action = ActionYank
case "t", "T", "f", "F":
m.command.Buffer = msg.String()
switch msg.String() {
case "t":
m.command.Seeking = SeekForwardUntil
case "T":
m.command.Seeking = SeekBackwardUntil
case "f":
m.command.Seeking = SeekForwardTo
case "F":
m.command.Seeking = SeekBackwardTo
}
case "r":
m.command.Action = ActionReplace
case "i":
if m.command.Action != ActionMove {
m.command.Buffer = "i"
m.command.Seeking = SeekInner
return nil
}
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "I":
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: 0},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "a":
if m.command.Action != ActionMove {
m.command.Buffer = "a"
m.command.Seeking = SeekAround
return nil
}
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col + 1},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "A":
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: len(m.value[m.row]) + 1},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "^":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, 0},
}
return executeCmd(*m.command)
case "$":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, len(m.value[m.row])},
}
return executeCmd(*m.command)
case "e", "E":
end := m.findWordEndRight(max(m.command.Count, 1), msg.String() == "E")
if m.command.Action == ActionDelete || m.command.Action == ActionChange {
end.Col = min(end.Col+1, len(m.value[end.Row]))
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
return executeCmd(*m.command)
case "w", "W":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: m.findWordStartRight(max(m.command.Count, 1), msg.String() == "W"),
}
return executeCmd(*m.command)
case "b", "B":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: m.findWordLeft(max(m.command.Count, 1), msg.String() == "B"),
}
return executeCmd(*m.command)
case "h", "l":
direction := 1
if msg.String() == "h" {
direction = -1
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, clamp(m.col+(direction*max(m.command.Count, 1)), 0, len(m.value[m.row]))},
}
return executeCmd(*m.command)
case "j", "k":
direction := 1
if msg.String() == "k" {
direction = -1
}
row := clamp(m.row+(direction*max(m.command.Count, 1)), 0, len(m.value)-1)
li := m.LineInfo()
charOffset := max(m.lastCharOffset, li.CharOffset)
m.lastCharOffset = charOffset
rowContent := m.value[row]
charWidth := rw.StringWidth(string(rowContent))
col := 0
offset := 0
for offset < charOffset {
if col > len(m.value[row]) || offset >= charWidth-1 {
break
}
offset += rw.RuneWidth(m.value[row][col])
col++
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{row, col},
}
return executeCmd(*m.command)
case "C":
m.deleteAfterCursor()
m.command = &NormalCommand{}
return m.SetMode(ModeInsert)
case "D":
m.deleteAfterCursor()
m.command = &NormalCommand{}
return nil
case "J":
m.CursorEnd()
m.mergeLineBelow(m.row)
m.command = &NormalCommand{}
return nil
case "p":
return Paste
}
case executeMsg:
switch m.command.Action {
case ActionDelete:
m.deleteRange(m.command.Range)
case ActionChange:
m.deleteRange(m.command.Range)
return m.SetMode(ModeInsert)
case ActionMove:
row := clamp(m.command.Range.End.Row, 0, len(m.value)-1)
m.row = row
m.col = clamp(m.command.Range.End.Col, 0, len(m.value[row]))
}
m.command = &NormalCommand{}
case pasteMsg:
m.handlePaste(string(msg))
}
return nil
}
func (m *Model) seekingUpdate(msg tea.KeyMsg) tea.Cmd {
if len(msg.Runes) <= 0 {
m.command = &NormalCommand{}
}
if msg.Runes[0] == 'w' || msg.Runes[0] == 'W' {
m.command.Range = m.findPairRange(msg.Runes[0])
return executeCmd(*m.command)
}
switch m.command.Buffer {
case "a":
m.command.Range = m.findPairRange(msg.Runes[0])
return executeCmd(*m.command)
case "i":
pr := m.findPairRange(msg.Runes[0])
pr.Start.Col++
pr.End.Col--
m.command.Range = pr
return executeCmd(*m.command)
case "f":
end := m.findCharRight(msg.Runes[0])
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
case "F":
start := m.findCharLeft(msg.Runes[0])
m.command.Range = Range{
Start: start,
End: Position{m.row, m.col},
}
case "t":
end := m.findCharRight(msg.Runes[0])
end.Col--
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
case "T":
start := m.findCharLeft(msg.Runes[0])
start.Col++
m.command.Range = Range{
Start: start,
End: Position{m.row, m.col},
}
}
return executeCmd(*m.command)
}
func (m *Model) findCharLeft(r rune) Position {
col := m.col
for col > 0 {
col--
if m.value[m.row][col] == r {
return Position{m.row, col}
}
}
return Position{m.row, m.col}
}
func (m *Model) findCharRight(r rune) Position {
col := m.col
for col < len(m.value[m.row]) {
if m.value[m.row][col] == r {
return Position{m.row, col}
}
col++
}
return Position{m.row, m.col}
}
func (m *Model) findPairRange(r rune) Range {
var startRune, endRune rune
switch r {
case 'w', 'W':
return Range{
Start: m.findWordLeft(0, r == 'W'),
End: m.findWordEndRight(1, true),
}
case '(', ')':
startRune, endRune = '(', ')'
case '{', '}':
startRune, endRune = '{', '}'
case '[', ']':
startRune, endRune = '[', ']'
case '<', '>':
startRune, endRune = '<', '>'
case '"', '\'':
startRune, endRune = r, r
default:
m.command = &NormalCommand{}
return Range{
Start: Position{m.row, m.col},
End: Position{m.row, m.col},
}
}
return Range{
Start: m.findCharLeft(startRune),
End: m.findCharRight(endRune),
}
}
// findWordLeft locates the start of the word on the left of the current word.
// It takes whether or not to break words on spaces or any non-alpha-numeric
// character as an argument.
func (m *Model) findWordLeft(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
if count == 0 {
if wordBreak(m.value[m.row][m.col-1]) {
return Position{m.row, m.col}
}
count = 1
}
row, col := m.row, m.col
for count > 0 {
if col <= 0 && row > 0 {
row--
col = len(m.value[row]) - 1
}
// Skip all spaces (and punctuation) to the left of the cursor.
for col > 0 && wordBreak(m.value[row][col-1]) {
col--
}
// Then skip all non-spaces to the left of the cursor.
for col > 0 && !wordBreak(m.value[row][col-1]) {
col--
}
count--
if row <= 0 && col <= 0 {
break
}
}
return Position{Row: row, Col: col}
}
// findWordStartRight locates the start of the next word. It takes whether or not to
// break words on spaces or any non-alpha-numeric character as an argument.
func (m *Model) findWordStartRight(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
row, col := m.row, m.col
for count > 0 {
if col >= len(m.value[row])-1 && row < len(m.value)-1 {
row++
col = 0
}
// Skip until the start of a word is found.
for col < len(m.value[row]) && !wordBreak(m.value[row][col]) {
col++
}
// Skip all spaces to the right of the cursor.
for col < len(m.value[row])-1 && wordBreak(m.value[row][col]) {
col++
}
count--
if row >= len(m.value)-1 && col >= len(m.value[row])-1 {
break
}
}
return Position{Row: row, Col: col}
}
// findWordEndRight locates the start of the next word. It takes whether or not to
// break words on spaces or any non-alpha-numeric character as an argument.
func (m *Model) findWordEndRight(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
row, col := m.row, m.col
for count > 0 {
if col > len(m.value[row]) && row < len(m.value)-1 {
row++
col = 0
}
// Skip all spaces to the right of the cursor.
for col < len(m.value[row])-1 && wordBreak(m.value[row][col+1]) {
col++
}
// Then skip all non-spaces to the right of the cursor.
for col < len(m.value[row])-1 && !wordBreak(m.value[row][col+1]) {
col++
}
count--
if row <= 0 && col <= 0 {
break
}
}
return Position{Row: row, Col: col}
}
func isWordBreak(char rune) bool {
return unicode.IsSpace(char)
}
func isSoftWordBreak(char rune) bool {
return unicode.IsSpace(char) || unicode.IsPunct(char)
}
func (m *Model) deleteRange(r Range) {
if r.Start.Row == r.End.Row && r.Start.Col == r.End.Col {
return
}
minCol, maxCol := min(r.Start.Col, r.End.Col), max(r.Start.Col, r.End.Col)
minCol = clamp(minCol, 0, len(m.value[r.Start.Row]))
maxCol = clamp(maxCol, 0, len(m.value[r.Start.Row]))
if r.Start.Row == r.End.Row {
// If the action is delete and from a seek action, we need to delete
// the range inclusively.
if m.command.IsSeeking() {
if m.command.IsSeekingForward() {
maxCol = min(maxCol+1, len(m.value[r.Start.Row]))
} else if m.command.IsSeekingBackward() {
minCol = max(minCol-1, 0)
} else if m.command.Seeking == SeekAround || m.command.Seeking == SeekInner {
maxCol = min(maxCol+1, len(m.value[r.Start.Row]))
}
}
m.value[r.Start.Row] = append(m.value[r.Start.Row][:minCol], m.value[r.Start.Row][maxCol:]...)
m.SetCursor(minCol)
return
}
minRow, maxRow := min(r.Start.Row, r.End.Row), max(r.Start.Row, r.End.Row)
if maxRow-minRow == 1 {
if r.Start.Row < r.End.Row {
m.value[r.Start.Row] = append([]rune{}, m.value[r.Start.Row][:r.Start.Col]...)
m.value[r.End.Row] = append([]rune{}, m.value[r.End.Row][r.End.Col:]...)
m.mergeLineBelow(minRow)
} else {
m.value[r.Start.Row] = append([]rune{}, m.value[r.Start.Row][r.Start.Col:]...)
m.value[r.End.Row] = append([]rune{}, m.value[r.End.Row][:r.End.Col]...)
m.mergeLineAbove(maxRow)
}
return
}
for i := max(minRow, 0); i <= min(maxRow, len(m.value)-1); i++ {
m.value[i] = []rune{}
}
m.value = append(m.value[:minRow], m.value[maxRow:]...)
m.row = clamp(0, minRow, len(m.value))
}

1016
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

@@ -205,7 +205,7 @@ func (m *Model) SetValue(s string) {
} else {
m.value = runes
}
if (m.pos == 0 && len(m.value) == 0) || m.pos > len(m.value) {
if m.pos == 0 || m.pos > len(m.value) {
m.setCursor(len(m.value))
}
m.handleOverflow()
@@ -611,7 +611,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyBackspace: // delete character before cursor
case tea.KeyBackspace, tea.KeyCtrlH: // delete character before cursor
m.Err = nil
if msg.Alt {

View File

@@ -162,10 +162,8 @@ 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)
}
}
// Toggle stops the timer if it's running and starts it if it's stopped.
func (m *Model) Toggle() tea.Cmd {

View File

@@ -237,7 +237,7 @@ func Sync(m Model) tea.Cmd {
}
// ViewDown is a high performance command that moves the viewport up by a given
// numer of lines. Use Model.ViewDown to get the lines that should be rendered.
// number of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
@@ -344,10 +344,10 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're going to be rendering the actual
// content seprately. We still need to send something that equals the
// content separately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", m.Height-1)
return strings.Repeat("\n", max(0, m.Height-1))
}
lines := m.visibleLines()