mirror of
https://github.com/Maks1mS/bubbles.git
synced 2025-10-18 16:38:56 +03:00
Compare commits
10 Commits
stable-v0.
...
go-bump-11
Author | SHA1 | Date | |
---|---|---|---|
|
8d709dc384 | ||
|
d4eeb4f5be | ||
|
b74f696cc8 | ||
|
3a34fc4ff8 | ||
|
2a2fb10f5f | ||
|
588393fbf8 | ||
|
7cc5786984 | ||
|
3899e1b4cd | ||
|
4c1762413a | ||
|
2fd583c8ef |
12
README.md
12
README.md
@@ -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, 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)
|
* [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
|
## Progress
|
||||||
|
|
||||||
|
207
cursor/cursor.go
Normal file
207
cursor/cursor.go
Normal 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
18
go.mod
@@ -1,16 +1,26 @@
|
|||||||
module github.com/charmbracelet/bubbles
|
module github.com/charmbracelet/bubbles
|
||||||
|
|
||||||
go 1.13
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4
|
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/harmonica v0.2.0
|
||||||
github.com/charmbracelet/lipgloss v0.5.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/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/mattn/go-runewidth v0.0.13
|
github.com/mattn/go-runewidth v0.0.13
|
||||||
github.com/muesli/reflow v0.3.0
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
|
github.com/muesli/termenv v0.12.0
|
||||||
github.com/sahilm/fuzzy v0.1.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
14
go.sum
@@ -1,7 +1,7 @@
|
|||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
|
github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
|
||||||
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
|
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 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
|
github.com/charmbracelet/lipgloss v0.5.0 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/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||||
github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
|
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
|
||||||
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
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/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.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 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
|
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
|
||||||
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
@@ -106,7 +106,7 @@ func (b Binding) Help() Help {
|
|||||||
// keybindings won't be activated and won't show up in help. Keybindings are
|
// keybindings won't be activated and won't show up in help. Keybindings are
|
||||||
// enabled by default.
|
// enabled by default.
|
||||||
func (b Binding) Enabled() bool {
|
func (b Binding) Enabled() bool {
|
||||||
return !b.disabled
|
return !b.disabled || b.keys == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEnabled enables or disables the keybinding.
|
// SetEnabled enables or disables the keybinding.
|
||||||
|
@@ -534,6 +534,15 @@ func (m Model) SettingFilter() bool {
|
|||||||
return m.filterState == Filtering
|
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.
|
// Width returns the current width setting.
|
||||||
func (m Model) Width() int {
|
func (m Model) Width() int {
|
||||||
return m.width
|
return m.width
|
||||||
|
1130
textarea/textarea.go
Normal file
1130
textarea/textarea.go
Normal file
File diff suppressed because it is too large
Load Diff
446
textarea/textarea_test.go
Normal file
446
textarea/textarea_test.go
Normal 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}
|
||||||
|
}
|
Reference in New Issue
Block a user