mirror of
https://github.com/charmbracelet/bubbles.git
synced 2026-07-21 02:59:28 -06:00
When Width is set, View pads the input out to that width with spaces. Both the text path and the placeholder path were running those padding spaces through the active style, so setting a background color on either Text or Placeholder caused the bg to extend well past the actual content, which doesn't match how a placeholder usually reads in CSS terms (you expect the style to scope to the text itself). Drop the styling on the padding in both code paths so the two views behave the same way: style covers the text, the rest is empty space. Closes #245. Signed-off-by: Charlie Tonneslan <cst0520@gmail.com>
972 lines
25 KiB
Go
972 lines
25 KiB
Go
// Package textinput provides a text input component for Bubble Tea
|
|
// applications.
|
|
package textinput
|
|
|
|
import (
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"charm.land/bubbles/v2/cursor"
|
|
"charm.land/bubbles/v2/internal/runeutil"
|
|
"charm.land/bubbles/v2/key"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/atotto/clipboard"
|
|
rw "github.com/mattn/go-runewidth"
|
|
"github.com/rivo/uniseg"
|
|
)
|
|
|
|
// Internal messages for clipboard operations.
|
|
type (
|
|
pasteMsg string
|
|
pasteErrMsg struct{ error }
|
|
)
|
|
|
|
// EchoMode sets the input behavior of the text input field.
|
|
type EchoMode int
|
|
|
|
const (
|
|
// EchoNormal displays text as is. This is the default behavior.
|
|
EchoNormal EchoMode = iota
|
|
|
|
// EchoPassword displays the EchoCharacter mask instead of actual
|
|
// characters. This is commonly used for password fields.
|
|
EchoPassword
|
|
|
|
// EchoNone displays nothing as characters are entered. This is commonly
|
|
// seen for password fields on the command line.
|
|
EchoNone
|
|
)
|
|
|
|
// ValidateFunc is a function that returns an error if the input is invalid.
|
|
type ValidateFunc func(string) error
|
|
|
|
// KeyMap is the key bindings for different actions within the textinput.
|
|
type KeyMap struct {
|
|
CharacterForward key.Binding
|
|
CharacterBackward key.Binding
|
|
WordForward key.Binding
|
|
WordBackward key.Binding
|
|
DeleteWordBackward key.Binding
|
|
DeleteWordForward key.Binding
|
|
DeleteAfterCursor key.Binding
|
|
DeleteBeforeCursor key.Binding
|
|
DeleteCharacterBackward key.Binding
|
|
DeleteCharacterForward key.Binding
|
|
LineStart key.Binding
|
|
LineEnd key.Binding
|
|
Paste key.Binding
|
|
AcceptSuggestion key.Binding
|
|
NextSuggestion key.Binding
|
|
PrevSuggestion key.Binding
|
|
}
|
|
|
|
// DefaultKeyMap is the default set of key bindings for navigating and acting
|
|
// upon the textinput.
|
|
func DefaultKeyMap() KeyMap {
|
|
return KeyMap{
|
|
CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
|
|
CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
|
|
WordForward: key.NewBinding(key.WithKeys("alt+right", "ctrl+right", "alt+f")),
|
|
WordBackward: key.NewBinding(key.WithKeys("alt+left", "ctrl+left", "alt+b")),
|
|
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
|
|
DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")),
|
|
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
|
|
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
|
|
DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
|
|
DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
|
|
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
|
|
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
|
|
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
|
|
AcceptSuggestion: key.NewBinding(key.WithKeys("tab")),
|
|
NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")),
|
|
PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")),
|
|
}
|
|
}
|
|
|
|
// Model is the Bubble Tea model for this text input element.
|
|
type Model struct {
|
|
Err error
|
|
|
|
// General settings.
|
|
Prompt string
|
|
Placeholder string
|
|
EchoMode EchoMode
|
|
EchoCharacter rune
|
|
|
|
// useVirtualCursor determines whether or not to use the virtual cursor. If
|
|
// set to false, use [Model.Cursor] to return a real cursor for rendering.
|
|
useVirtualCursor bool
|
|
|
|
// Virtual cursor manager.
|
|
virtualCursor cursor.Model
|
|
|
|
// CharLimit is the maximum amount of characters this input element will
|
|
// accept. If 0 or less, there's no limit.
|
|
CharLimit int
|
|
|
|
// Styling. FocusedStyle and BlurredStyle are used to style the textarea in
|
|
// focused and blurred states.
|
|
styles Styles
|
|
|
|
// Width is the maximum number of characters that can be displayed at once.
|
|
// It essentially treats the text field like a horizontally scrolling
|
|
// viewport. If 0 or less this setting is ignored.
|
|
width int
|
|
|
|
// KeyMap encodes the keybindings recognized by the widget.
|
|
KeyMap KeyMap
|
|
|
|
// Underlying text value.
|
|
value []rune
|
|
|
|
// focus indicates whether user input focus should be on this input
|
|
// component. When false, ignore keyboard input and hide the cursor.
|
|
focus bool
|
|
|
|
// Cursor position.
|
|
pos int
|
|
|
|
// Used to emulate a viewport when width is set and the content is
|
|
// overflowing.
|
|
offset int
|
|
offsetRight int
|
|
|
|
// 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
|
|
|
|
// rune sanitizer for input.
|
|
rsan runeutil.Sanitizer
|
|
|
|
// Should the input suggest to complete
|
|
ShowSuggestions bool
|
|
|
|
// suggestions is a list of suggestions that may be used to complete the
|
|
// input.
|
|
suggestions [][]rune
|
|
matchedSuggestions [][]rune
|
|
currentSuggestionIndex int
|
|
}
|
|
|
|
// New creates a new model with default settings.
|
|
func New() Model {
|
|
m := Model{
|
|
Prompt: "> ",
|
|
EchoCharacter: '*',
|
|
CharLimit: 0,
|
|
styles: DefaultDarkStyles(),
|
|
ShowSuggestions: false,
|
|
useVirtualCursor: true,
|
|
virtualCursor: cursor.New(),
|
|
KeyMap: DefaultKeyMap(),
|
|
suggestions: [][]rune{},
|
|
value: nil,
|
|
focus: false,
|
|
pos: 0,
|
|
}
|
|
m.updateVirtualCursorStyle()
|
|
return m
|
|
}
|
|
|
|
// VirtualCursor returns whether the model is using a virtual cursor.
|
|
func (m Model) VirtualCursor() bool {
|
|
return m.useVirtualCursor
|
|
}
|
|
|
|
// SetVirtualCursor sets whether the model should use a virtual cursor. If
|
|
// disabled, use [Model.Cursor] to return a real cursor for rendering.
|
|
func (m *Model) SetVirtualCursor(v bool) {
|
|
m.useVirtualCursor = v
|
|
m.updateVirtualCursorStyle()
|
|
}
|
|
|
|
// Styles returns the current set of styles.
|
|
func (m Model) Styles() Styles {
|
|
return m.styles
|
|
}
|
|
|
|
// SetStyles sets the styles for the text input.
|
|
func (m *Model) SetStyles(s Styles) {
|
|
m.styles = s
|
|
m.updateVirtualCursorStyle()
|
|
}
|
|
|
|
// Width returns the width of the text input.
|
|
func (m Model) Width() int {
|
|
return m.width
|
|
}
|
|
|
|
// SetWidth sets the width of the text input.
|
|
func (m *Model) SetWidth(w int) {
|
|
m.width = w
|
|
}
|
|
|
|
// SetValue sets the value of the text input.
|
|
func (m *Model) SetValue(s string) {
|
|
// Clean up any special characters in the input provided by the
|
|
// caller. This avoids bugs due to e.g. tab characters and whatnot.
|
|
runes := m.san().Sanitize([]rune(s))
|
|
err := m.validate(runes)
|
|
m.setValueInternal(runes, err)
|
|
}
|
|
|
|
func (m *Model) setValueInternal(runes []rune, err error) {
|
|
m.Err = err
|
|
|
|
empty := len(m.value) == 0
|
|
|
|
if m.CharLimit > 0 && len(runes) > m.CharLimit {
|
|
m.value = runes[:m.CharLimit]
|
|
} else {
|
|
m.value = runes
|
|
}
|
|
if (m.pos == 0 && empty) || m.pos > len(m.value) {
|
|
m.SetCursor(len(m.value))
|
|
}
|
|
m.handleOverflow()
|
|
}
|
|
|
|
// Value returns the value of the text input.
|
|
func (m Model) Value() string {
|
|
return string(m.value)
|
|
}
|
|
|
|
// Position returns the cursor position.
|
|
func (m Model) Position() int {
|
|
return m.pos
|
|
}
|
|
|
|
// SetCursor moves the cursor to the given position. If the position is
|
|
// out of bounds the cursor will be moved to the start or end accordingly.
|
|
func (m *Model) SetCursor(pos int) {
|
|
m.pos = clamp(pos, 0, len(m.value))
|
|
m.handleOverflow()
|
|
}
|
|
|
|
// CursorStart moves the cursor to the start of the input field.
|
|
func (m *Model) CursorStart() {
|
|
m.SetCursor(0)
|
|
}
|
|
|
|
// CursorEnd moves the cursor to the end of the input field.
|
|
func (m *Model) CursorEnd() {
|
|
m.SetCursor(len(m.value))
|
|
}
|
|
|
|
// Focused returns the focus state on the model.
|
|
func (m Model) Focused() bool {
|
|
return m.focus
|
|
}
|
|
|
|
// Focus sets the focus state on the model. When the model is in focus it can
|
|
// receive keyboard input and the cursor will be shown.
|
|
func (m *Model) Focus() tea.Cmd {
|
|
m.focus = true
|
|
return m.virtualCursor.Focus()
|
|
}
|
|
|
|
// Blur removes the focus state on the model. When the model is blurred it can
|
|
// not receive keyboard input and the cursor will be hidden.
|
|
func (m *Model) Blur() {
|
|
m.focus = false
|
|
m.virtualCursor.Blur()
|
|
}
|
|
|
|
// Reset sets the input to its default state with no input.
|
|
func (m *Model) Reset() {
|
|
m.value = nil
|
|
m.SetCursor(0)
|
|
}
|
|
|
|
// SetSuggestions sets the suggestions for the input.
|
|
func (m *Model) SetSuggestions(suggestions []string) {
|
|
m.suggestions = make([][]rune, len(suggestions))
|
|
for i, s := range suggestions {
|
|
m.suggestions[i] = []rune(s)
|
|
}
|
|
|
|
m.updateSuggestions()
|
|
}
|
|
|
|
// rsan initializes or retrieves the rune sanitizer.
|
|
func (m *Model) san() runeutil.Sanitizer {
|
|
if m.rsan == nil {
|
|
// Textinput has all its input on a single line so collapse
|
|
// newlines/tabs to single spaces.
|
|
m.rsan = runeutil.NewSanitizer(
|
|
runeutil.ReplaceTabs(" "), runeutil.ReplaceNewlines(" "))
|
|
}
|
|
return m.rsan
|
|
}
|
|
|
|
func (m *Model) insertRunesFromUserInput(v []rune) {
|
|
// Clean up any special characters in the input provided by the
|
|
// clipboard. This avoids bugs due to e.g. tab characters and
|
|
// whatnot.
|
|
paste := m.san().Sanitize(v)
|
|
|
|
var availSpace int
|
|
if m.CharLimit > 0 {
|
|
availSpace = m.CharLimit - len(m.value)
|
|
|
|
// If the char limit's been reached, cancel.
|
|
if availSpace <= 0 {
|
|
return
|
|
}
|
|
|
|
// If there's not enough space to paste the whole thing cut the pasted
|
|
// runes down so they'll fit.
|
|
if availSpace < len(paste) {
|
|
paste = paste[:availSpace]
|
|
}
|
|
}
|
|
|
|
// Stuff before and after the cursor
|
|
head := m.value[:m.pos]
|
|
tailSrc := m.value[m.pos:]
|
|
tail := make([]rune, len(tailSrc))
|
|
copy(tail, tailSrc)
|
|
|
|
// Insert pasted runes
|
|
for _, r := range paste {
|
|
head = append(head, r)
|
|
m.pos++
|
|
if m.CharLimit > 0 {
|
|
availSpace--
|
|
if availSpace <= 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Put it all back together
|
|
value := append(head, tail...)
|
|
inputErr := m.validate(value)
|
|
m.setValueInternal(value, inputErr)
|
|
}
|
|
|
|
// If a max width is defined, perform some logic to treat the visible area
|
|
// as a horizontally scrolling viewport.
|
|
func (m *Model) handleOverflow() {
|
|
if m.Width() <= 0 || uniseg.StringWidth(string(m.value)) <= m.Width() {
|
|
m.offset = 0
|
|
m.offsetRight = len(m.value)
|
|
return
|
|
}
|
|
|
|
// Correct right offset if we've deleted characters
|
|
m.offsetRight = min(m.offsetRight, len(m.value))
|
|
|
|
if m.pos < m.offset {
|
|
m.offset = m.pos
|
|
|
|
w := 0
|
|
i := 0
|
|
runes := m.value[m.offset:]
|
|
|
|
for i < len(runes) && w <= m.Width() {
|
|
w += rw.RuneWidth(runes[i])
|
|
if w <= m.Width()+1 {
|
|
i++
|
|
}
|
|
}
|
|
|
|
m.offsetRight = m.offset + i
|
|
} else if m.pos >= m.offsetRight {
|
|
m.offsetRight = m.pos
|
|
|
|
w := 0
|
|
runes := m.value[:m.offsetRight]
|
|
i := len(runes) - 1
|
|
|
|
for i > 0 && w < m.Width() {
|
|
w += rw.RuneWidth(runes[i])
|
|
if w <= m.Width() {
|
|
i--
|
|
}
|
|
}
|
|
|
|
m.offset = m.offsetRight - (len(runes) - 1 - i)
|
|
}
|
|
}
|
|
|
|
// deleteBeforeCursor deletes all text before the cursor.
|
|
func (m *Model) deleteBeforeCursor() {
|
|
m.value = m.value[m.pos:]
|
|
m.Err = m.validate(m.value)
|
|
m.offset = 0
|
|
m.SetCursor(0)
|
|
}
|
|
|
|
// deleteAfterCursor deletes all text after the cursor. If input is masked
|
|
// delete everything after the cursor so as not to reveal word breaks in the
|
|
// masked input.
|
|
func (m *Model) deleteAfterCursor() {
|
|
m.value = m.value[:m.pos]
|
|
m.Err = m.validate(m.value)
|
|
m.SetCursor(len(m.value))
|
|
}
|
|
|
|
// deleteWordBackward deletes the word left to the cursor.
|
|
func (m *Model) deleteWordBackward() {
|
|
if m.pos == 0 || len(m.value) == 0 {
|
|
return
|
|
}
|
|
|
|
if m.EchoMode != EchoNormal {
|
|
m.deleteBeforeCursor()
|
|
return
|
|
}
|
|
|
|
// Linter note: it's critical that we acquire the initial cursor position
|
|
// here prior to altering it via SetCursor() below. As such, moving this
|
|
// call into the corresponding if clause does not apply here.
|
|
oldPos := m.pos
|
|
|
|
m.SetCursor(m.pos - 1)
|
|
for unicode.IsSpace(m.value[m.pos]) {
|
|
if m.pos <= 0 {
|
|
break
|
|
}
|
|
// ignore series of whitespace before cursor
|
|
m.SetCursor(m.pos - 1)
|
|
}
|
|
|
|
for m.pos > 0 {
|
|
if !unicode.IsSpace(m.value[m.pos]) {
|
|
m.SetCursor(m.pos - 1)
|
|
} else {
|
|
if m.pos > 0 {
|
|
// keep the previous space
|
|
m.SetCursor(m.pos + 1)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if oldPos > len(m.value) {
|
|
m.value = m.value[:m.pos]
|
|
} else {
|
|
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
|
|
}
|
|
m.Err = m.validate(m.value)
|
|
}
|
|
|
|
// deleteWordForward deletes the word right to the cursor. If input is masked
|
|
// delete everything after the cursor so as not to reveal word breaks in the
|
|
// masked input.
|
|
func (m *Model) deleteWordForward() {
|
|
if m.pos >= len(m.value) || len(m.value) == 0 {
|
|
return
|
|
}
|
|
|
|
if m.EchoMode != EchoNormal {
|
|
m.deleteAfterCursor()
|
|
return
|
|
}
|
|
|
|
oldPos := m.pos
|
|
m.SetCursor(m.pos + 1)
|
|
for unicode.IsSpace(m.value[m.pos]) {
|
|
// ignore series of whitespace after cursor
|
|
m.SetCursor(m.pos + 1)
|
|
|
|
if m.pos >= len(m.value) {
|
|
break
|
|
}
|
|
}
|
|
|
|
for m.pos < len(m.value) {
|
|
if !unicode.IsSpace(m.value[m.pos]) {
|
|
m.SetCursor(m.pos + 1)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if m.pos > len(m.value) {
|
|
m.value = m.value[:oldPos]
|
|
} else {
|
|
m.value = append(m.value[:oldPos], m.value[m.pos:]...)
|
|
}
|
|
m.Err = m.validate(m.value)
|
|
|
|
m.SetCursor(oldPos)
|
|
}
|
|
|
|
// wordBackward moves the cursor one word to the left. If input is masked, move
|
|
// input to the start so as not to reveal word breaks in the masked input.
|
|
func (m *Model) wordBackward() {
|
|
if m.pos == 0 || len(m.value) == 0 {
|
|
return
|
|
}
|
|
|
|
if m.EchoMode != EchoNormal {
|
|
m.CursorStart()
|
|
return
|
|
}
|
|
|
|
i := m.pos - 1
|
|
for i >= 0 {
|
|
if unicode.IsSpace(m.value[i]) {
|
|
m.SetCursor(m.pos - 1)
|
|
i--
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
for i >= 0 {
|
|
if !unicode.IsSpace(m.value[i]) {
|
|
m.SetCursor(m.pos - 1)
|
|
i--
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// wordForward moves the cursor one word to the right. If the input is masked,
|
|
// move input to the end so as not to reveal word breaks in the masked input.
|
|
func (m *Model) wordForward() {
|
|
if m.pos >= len(m.value) || len(m.value) == 0 {
|
|
return
|
|
}
|
|
|
|
if m.EchoMode != EchoNormal {
|
|
m.CursorEnd()
|
|
return
|
|
}
|
|
|
|
i := m.pos
|
|
for i < len(m.value) {
|
|
if unicode.IsSpace(m.value[i]) {
|
|
m.SetCursor(m.pos + 1)
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
for i < len(m.value) {
|
|
if !unicode.IsSpace(m.value[i]) {
|
|
m.SetCursor(m.pos + 1)
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m Model) echoTransform(v string) string {
|
|
switch m.EchoMode {
|
|
case EchoPassword:
|
|
return strings.Repeat(string(m.EchoCharacter), uniseg.StringWidth(v))
|
|
case EchoNone:
|
|
return ""
|
|
case EchoNormal:
|
|
return v
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// Update is the Bubble Tea update loop.
|
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
if !m.focus {
|
|
return m, nil
|
|
}
|
|
|
|
// Need to check for completion before, because key is configurable and might be double assigned
|
|
keyMsg, ok := msg.(tea.KeyPressMsg)
|
|
if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) {
|
|
if m.canAcceptSuggestion() {
|
|
m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...)
|
|
m.CursorEnd()
|
|
}
|
|
}
|
|
|
|
// Let's remember where the position of the cursor currently is so that if
|
|
// the cursor position changes, we can reset the blink.
|
|
oldPos := m.pos
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyPressMsg:
|
|
switch {
|
|
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
|
|
m.deleteWordBackward()
|
|
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
|
|
m.Err = nil
|
|
if len(m.value) > 0 {
|
|
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
|
|
m.Err = m.validate(m.value)
|
|
if m.pos > 0 {
|
|
m.SetCursor(m.pos - 1)
|
|
}
|
|
}
|
|
case key.Matches(msg, m.KeyMap.WordBackward):
|
|
m.wordBackward()
|
|
case key.Matches(msg, m.KeyMap.CharacterBackward):
|
|
if m.pos > 0 {
|
|
m.SetCursor(m.pos - 1)
|
|
}
|
|
case key.Matches(msg, m.KeyMap.WordForward):
|
|
m.wordForward()
|
|
case key.Matches(msg, m.KeyMap.CharacterForward):
|
|
if m.pos < len(m.value) {
|
|
m.SetCursor(m.pos + 1)
|
|
}
|
|
case key.Matches(msg, m.KeyMap.LineStart):
|
|
m.CursorStart()
|
|
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
|
|
if len(m.value) > 0 && m.pos < len(m.value) {
|
|
m.value = slices.Delete(m.value, m.pos, m.pos+1)
|
|
m.Err = m.validate(m.value)
|
|
}
|
|
case key.Matches(msg, m.KeyMap.LineEnd):
|
|
m.CursorEnd()
|
|
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
|
|
m.deleteAfterCursor()
|
|
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
|
|
m.deleteBeforeCursor()
|
|
case key.Matches(msg, m.KeyMap.Paste):
|
|
return m, Paste
|
|
case key.Matches(msg, m.KeyMap.DeleteWordForward):
|
|
m.deleteWordForward()
|
|
case key.Matches(msg, m.KeyMap.NextSuggestion):
|
|
m.nextSuggestion()
|
|
case key.Matches(msg, m.KeyMap.PrevSuggestion):
|
|
m.previousSuggestion()
|
|
default:
|
|
// Input one or more regular characters.
|
|
m.insertRunesFromUserInput([]rune(msg.Text))
|
|
}
|
|
|
|
// Check again if can be completed
|
|
// because value might be something that does not match the completion prefix
|
|
m.updateSuggestions()
|
|
|
|
case tea.PasteMsg:
|
|
m.insertRunesFromUserInput([]rune(msg.Content))
|
|
|
|
case pasteMsg:
|
|
m.insertRunesFromUserInput([]rune(msg))
|
|
|
|
case pasteErrMsg:
|
|
m.Err = msg
|
|
}
|
|
|
|
var cmds []tea.Cmd
|
|
var cmd tea.Cmd
|
|
|
|
if m.useVirtualCursor {
|
|
m.virtualCursor, cmd = m.virtualCursor.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// If the cursor position changed, reset the blink state. This is a
|
|
// small UX nuance that makes cursor movement obvious and feel snappy.
|
|
if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink {
|
|
m.virtualCursor.IsBlinked = false
|
|
cmds = append(cmds, m.virtualCursor.Blink())
|
|
}
|
|
}
|
|
|
|
m.handleOverflow()
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// View renders the textinput in its current state.
|
|
func (m Model) View() string {
|
|
// Placeholder text
|
|
if len(m.value) == 0 && m.Placeholder != "" {
|
|
return m.placeholderView()
|
|
}
|
|
|
|
styles := m.activeStyle()
|
|
|
|
styleText := styles.Text.Inline(true).Render
|
|
|
|
value := m.value[m.offset:m.offsetRight]
|
|
pos := max(0, m.pos-m.offset)
|
|
v := styleText(m.echoTransform(string(value[:pos])))
|
|
|
|
if pos < len(value) { //nolint:nestif
|
|
char := m.echoTransform(string(value[pos]))
|
|
m.virtualCursor.SetChar(char)
|
|
v += m.virtualCursor.View() // cursor and text under it
|
|
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
|
|
v += m.completionView(0) // suggested completion
|
|
} else {
|
|
if m.focus && m.canAcceptSuggestion() {
|
|
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
|
|
if len(value) < len(suggestion) {
|
|
m.virtualCursor.TextStyle = styles.Suggestion
|
|
m.virtualCursor.SetChar(m.echoTransform(string(suggestion[pos])))
|
|
v += m.virtualCursor.View()
|
|
v += m.completionView(1)
|
|
} else {
|
|
m.virtualCursor.SetChar(" ")
|
|
v += m.virtualCursor.View()
|
|
}
|
|
} else {
|
|
m.virtualCursor.SetChar(" ")
|
|
v += m.virtualCursor.View()
|
|
}
|
|
}
|
|
|
|
// Pad the rest of the input out to its full width. The padding spaces are
|
|
// left unstyled so that Text styling stops at the value's end, matching
|
|
// the placeholder's behavior. See #245.
|
|
valWidth := uniseg.StringWidth(string(value))
|
|
if m.Width() > 0 && valWidth <= m.Width() {
|
|
padding := max(0, m.Width()-valWidth)
|
|
if valWidth+padding <= m.Width() && pos < len(value) {
|
|
padding++
|
|
}
|
|
v += strings.Repeat(" ", padding)
|
|
}
|
|
|
|
return m.promptView() + v
|
|
}
|
|
|
|
func (m Model) promptView() string {
|
|
return m.activeStyle().Prompt.Render(m.Prompt)
|
|
}
|
|
|
|
// placeholderView returns the prompt and placeholder view, if any.
|
|
func (m Model) placeholderView() string {
|
|
var (
|
|
v string
|
|
styles = m.activeStyle()
|
|
render = styles.Placeholder.Render
|
|
)
|
|
|
|
p := make([]rune, m.Width()+1)
|
|
copy(p, []rune(m.Placeholder))
|
|
|
|
m.virtualCursor.TextStyle = styles.Placeholder
|
|
m.virtualCursor.SetChar(string(p[:1]))
|
|
v += m.virtualCursor.View()
|
|
|
|
// If the entire placeholder is already set and no padding is needed, finish
|
|
if m.Width() < 1 && len(p) <= 1 {
|
|
return styles.Prompt.Render(m.Prompt) + v
|
|
}
|
|
|
|
// If Width is set then size placeholder accordingly
|
|
if m.Width() > 0 {
|
|
// available width is width - len + cursor offset of 1
|
|
minWidth := lipgloss.Width(m.Placeholder)
|
|
availWidth := m.Width() - minWidth + 1
|
|
|
|
// if width < len, 'subtract'(add) number to len and dont add padding
|
|
if availWidth < 0 {
|
|
minWidth += availWidth
|
|
availWidth = 0
|
|
}
|
|
// append placeholder[len] - cursor, append padding (padding is
|
|
// intentionally unstyled so the placeholder color only covers the
|
|
// placeholder text itself, matching the text-mode behavior in View).
|
|
v += render(string(p[1:minWidth]))
|
|
v += strings.Repeat(" ", availWidth)
|
|
} else {
|
|
// if there is no width, the placeholder can be any length
|
|
v += render(string(p[1:]))
|
|
}
|
|
|
|
return styles.Prompt.Render(m.Prompt) + v
|
|
}
|
|
|
|
// Blink is a command used to initialize cursor blinking.
|
|
func Blink() tea.Msg {
|
|
return cursor.Blink()
|
|
}
|
|
|
|
// Paste is a command for pasting from the clipboard into the text input.
|
|
func Paste() tea.Msg {
|
|
str, err := clipboard.ReadAll()
|
|
if err != nil {
|
|
return pasteErrMsg{err}
|
|
}
|
|
return pasteMsg(str)
|
|
}
|
|
|
|
func clamp(v, low, high int) int {
|
|
if high < low {
|
|
low, high = high, low
|
|
}
|
|
return min(high, max(low, v))
|
|
}
|
|
|
|
func (m Model) completionView(offset int) string {
|
|
if !m.canAcceptSuggestion() {
|
|
return ""
|
|
}
|
|
value := m.value
|
|
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
|
|
if len(value) < len(suggestion) {
|
|
return m.activeStyle().Suggestion.Inline(true).
|
|
Render(string(suggestion[len(value)+offset:]))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Model) getSuggestions(sugs [][]rune) []string {
|
|
suggestions := make([]string, len(sugs))
|
|
for i, s := range sugs {
|
|
suggestions[i] = string(s)
|
|
}
|
|
return suggestions
|
|
}
|
|
|
|
// AvailableSuggestions returns the list of available suggestions.
|
|
func (m *Model) AvailableSuggestions() []string {
|
|
return m.getSuggestions(m.suggestions)
|
|
}
|
|
|
|
// MatchedSuggestions returns the list of matched suggestions.
|
|
func (m *Model) MatchedSuggestions() []string {
|
|
return m.getSuggestions(m.matchedSuggestions)
|
|
}
|
|
|
|
// CurrentSuggestionIndex returns the currently selected suggestion index.
|
|
func (m *Model) CurrentSuggestionIndex() int {
|
|
return m.currentSuggestionIndex
|
|
}
|
|
|
|
// CurrentSuggestion returns the currently selected suggestion.
|
|
func (m *Model) CurrentSuggestion() string {
|
|
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
|
|
return ""
|
|
}
|
|
|
|
return string(m.matchedSuggestions[m.currentSuggestionIndex])
|
|
}
|
|
|
|
// canAcceptSuggestion returns whether there is an acceptable suggestion to
|
|
// autocomplete the current value.
|
|
func (m *Model) canAcceptSuggestion() bool {
|
|
return len(m.matchedSuggestions) > 0
|
|
}
|
|
|
|
// updateSuggestions refreshes the list of matching suggestions.
|
|
func (m *Model) updateSuggestions() {
|
|
if !m.ShowSuggestions {
|
|
return
|
|
}
|
|
|
|
if len(m.value) <= 0 || len(m.suggestions) <= 0 {
|
|
m.matchedSuggestions = [][]rune{}
|
|
return
|
|
}
|
|
|
|
matches := [][]rune{}
|
|
for _, s := range m.suggestions {
|
|
suggestion := string(s)
|
|
|
|
if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(string(m.value))) {
|
|
matches = append(matches, []rune(suggestion))
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(matches, m.matchedSuggestions) {
|
|
m.currentSuggestionIndex = 0
|
|
}
|
|
|
|
m.matchedSuggestions = matches
|
|
}
|
|
|
|
// nextSuggestion selects the next suggestion.
|
|
func (m *Model) nextSuggestion() {
|
|
m.currentSuggestionIndex = (m.currentSuggestionIndex + 1)
|
|
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
|
|
m.currentSuggestionIndex = 0
|
|
}
|
|
}
|
|
|
|
// previousSuggestion selects the previous suggestion.
|
|
func (m *Model) previousSuggestion() {
|
|
m.currentSuggestionIndex = (m.currentSuggestionIndex - 1)
|
|
if m.currentSuggestionIndex < 0 {
|
|
m.currentSuggestionIndex = len(m.matchedSuggestions) - 1
|
|
}
|
|
}
|
|
|
|
func (m Model) validate(v []rune) error {
|
|
if m.Validate != nil {
|
|
return m.Validate(string(v))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
|
|
// program. This requires that [Model.VirtualCursor] is set to false.
|
|
//
|
|
// Note that you will almost certainly also need to adjust the offset cursor
|
|
// position per the textarea's per the textarea's position in the terminal.
|
|
//
|
|
// Example:
|
|
//
|
|
// // In your top-level View function:
|
|
// f := tea.NewFrame(m.textarea.View())
|
|
// f.Cursor = m.textarea.Cursor()
|
|
// f.Cursor.Position.X += offsetX
|
|
// f.Cursor.Position.Y += offsetY
|
|
func (m Model) Cursor() *tea.Cursor {
|
|
if m.useVirtualCursor || !m.Focused() {
|
|
return nil
|
|
}
|
|
|
|
w := lipgloss.Width
|
|
|
|
promptWidth := w(m.promptView())
|
|
xOffset := m.Position() +
|
|
promptWidth
|
|
if m.width > 0 {
|
|
xOffset = min(xOffset, m.width+promptWidth)
|
|
}
|
|
|
|
style := m.styles.Cursor
|
|
c := tea.NewCursor(xOffset, 0)
|
|
c.Blink = style.Blink
|
|
c.Color = style.Color
|
|
c.Shape = style.Shape
|
|
return c
|
|
}
|
|
|
|
// updateVirtualCursorStyle sets styling on the virtual cursor based on the
|
|
// textarea's style settings.
|
|
func (m *Model) updateVirtualCursorStyle() {
|
|
if !m.useVirtualCursor {
|
|
// Hide the virtual cursor if we're using a real cursor.
|
|
m.virtualCursor.SetMode(cursor.CursorHide)
|
|
return
|
|
}
|
|
|
|
m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.styles.Cursor.Color)
|
|
|
|
// By default, the blink speed of the cursor is set to a default
|
|
// internally.
|
|
if m.styles.Cursor.Blink {
|
|
if m.styles.Cursor.BlinkSpeed > 0 {
|
|
m.virtualCursor.BlinkSpeed = m.styles.Cursor.BlinkSpeed
|
|
}
|
|
m.virtualCursor.SetMode(cursor.CursorBlink)
|
|
return
|
|
}
|
|
m.virtualCursor.SetMode(cursor.CursorStatic)
|
|
}
|
|
|
|
// activeStyle returns the appropriate set of styles to use depending on
|
|
// whether the textarea is focused or blurred.
|
|
func (m Model) activeStyle() *StyleState {
|
|
if m.focus {
|
|
return &m.styles.Focused
|
|
}
|
|
return &m.styles.Blurred
|
|
}
|