mirror of
https://github.com/charmbracelet/bubbles.git
synced 2026-07-22 11:39:28 -06:00
feat: bracketed paste in textarea + textinput
This also fixes a longstanding bug where newlines, tabs or other control characters in the clipboard would cause the input to be corrupted on paste.
This commit is contained in:
parent
e78f923af6
commit
53724e6a72
102
runeutil/runeutil.go
Normal file
102
runeutil/runeutil.go
Normal file
@ -0,0 +1,102 @@
|
||||
// Package runeutil provides a utility function for use in Bubbles
|
||||
// that can process Key messages containing runes.
|
||||
package runeutil
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Sanitizer is a helper for bubble widgets that want to process
|
||||
// Runes from input key messages.
|
||||
type Sanitizer interface {
|
||||
// Sanitize removes control characters from runes in a KeyRunes
|
||||
// message, and optionally replaces newline/carriage return/tabs by a
|
||||
// specified character.
|
||||
//
|
||||
// The rune array is modified in-place if possible. In that case, the
|
||||
// returned slice is the original slice shortened after the control
|
||||
// characters have been removed/translated.
|
||||
Sanitize(runes []rune) []rune
|
||||
}
|
||||
|
||||
// NewSanitizer constructs a rune sanitizer.
|
||||
func NewSanitizer(opts ...Option) Sanitizer {
|
||||
s := sanitizer{
|
||||
replaceNewLine: []rune("\n"),
|
||||
replaceTab: []rune(" "),
|
||||
}
|
||||
for _, o := range opts {
|
||||
s = o(s)
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Option is the type of an option that can be passed to Sanitize().
|
||||
type Option func(sanitizer) sanitizer
|
||||
|
||||
// ReplaceTabs replaces tabs by the specified string.
|
||||
func ReplaceTabs(tabRepl string) Option {
|
||||
return func(s sanitizer) sanitizer {
|
||||
s.replaceTab = []rune(tabRepl)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceNewlines replaces newline characters by the specified string.
|
||||
func ReplaceNewlines(nlRepl string) Option {
|
||||
return func(s sanitizer) sanitizer {
|
||||
s.replaceNewLine = []rune(nlRepl)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sanitizer) Sanitize(runes []rune) []rune {
|
||||
// dstrunes are where we are storing the result.
|
||||
dstrunes := runes[:0:len(runes)]
|
||||
// copied indicates whether dstrunes is an alias of runes
|
||||
// or a copy. We need a copy when dst moves past src.
|
||||
// We use this as an optimization to avoid allocating
|
||||
// a new rune slice in the common case where the output
|
||||
// is smaller or equal to the input.
|
||||
copied := false
|
||||
|
||||
for src := 0; src < len(runes); src++ {
|
||||
r := runes[src]
|
||||
switch {
|
||||
case r == utf8.RuneError:
|
||||
// skip
|
||||
|
||||
case r == '\r' || r == '\n':
|
||||
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
|
||||
dst := len(dstrunes)
|
||||
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
|
||||
copy(dstrunes, runes[:dst])
|
||||
copied = true
|
||||
}
|
||||
dstrunes = append(dstrunes, s.replaceNewLine...)
|
||||
|
||||
case r == '\t':
|
||||
if len(dstrunes)+len(s.replaceTab) > src && !copied {
|
||||
dst := len(dstrunes)
|
||||
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
|
||||
copy(dstrunes, runes[:dst])
|
||||
copied = true
|
||||
}
|
||||
dstrunes = append(dstrunes, s.replaceTab...)
|
||||
|
||||
case unicode.IsControl(r):
|
||||
// Other control characters: skip.
|
||||
|
||||
default:
|
||||
// Keep the character.
|
||||
dstrunes = append(dstrunes, runes[src])
|
||||
}
|
||||
}
|
||||
return dstrunes
|
||||
}
|
||||
|
||||
type sanitizer struct {
|
||||
replaceNewLine []rune
|
||||
replaceTab []rune
|
||||
}
|
||||
44
runeutil/runeutil_test.go
Normal file
44
runeutil/runeutil_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
package runeutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestSanitize(t *testing.T) {
|
||||
td := []struct {
|
||||
input, output string
|
||||
}{
|
||||
{"", ""},
|
||||
{"x", "x"},
|
||||
{"\n", "XX"},
|
||||
{"\na\n", "XXaXX"},
|
||||
{"\n\n", "XXXX"},
|
||||
{"\t", ""},
|
||||
{"hello", "hello"},
|
||||
{"hel\nlo", "helXXlo"},
|
||||
{"hel\rlo", "helXXlo"},
|
||||
{"hel\tlo", "hello"},
|
||||
{"he\n\nl\tlo", "heXXXXllo"},
|
||||
{"he\tl\n\nlo", "helXXXXlo"},
|
||||
{"hel\x1blo", "hello"},
|
||||
{"hello\xc2", "hello"}, // invalid utf8
|
||||
}
|
||||
|
||||
for _, tc := range td {
|
||||
runes := make([]rune, 0, len(tc.input))
|
||||
b := []byte(tc.input)
|
||||
for i, w := 0, 0; i < len(b); i += w {
|
||||
var r rune
|
||||
r, w = utf8.DecodeRune(b[i:])
|
||||
runes = append(runes, r)
|
||||
}
|
||||
t.Logf("input runes: %+v", runes)
|
||||
s := NewSanitizer(ReplaceNewlines("XX"), ReplaceTabs(""))
|
||||
result := s.Sanitize(runes)
|
||||
rs := string(result)
|
||||
if tc.output != rs {
|
||||
t.Errorf("%q: expected %q, got %q (%+v)", tc.input, tc.output, rs, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/cursor"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/runeutil"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@ -212,6 +213,9 @@ type Model struct {
|
||||
// viewport is the vertically-scrollable viewport of the multi-line text
|
||||
// input.
|
||||
viewport *viewport.Model
|
||||
|
||||
// rune sanitizer for input.
|
||||
rsan runeutil.Sanitizer
|
||||
}
|
||||
|
||||
// New creates a new model with default settings.
|
||||
@ -283,26 +287,98 @@ func (m *Model) SetValue(s string) {
|
||||
|
||||
// InsertString inserts a string at the cursor position.
|
||||
func (m *Model) InsertString(s string) {
|
||||
lines := strings.Split(s, "\n")
|
||||
for l, line := range lines {
|
||||
for _, rune := range line {
|
||||
m.InsertRune(rune)
|
||||
}
|
||||
if l != len(lines)-1 {
|
||||
m.InsertRune('\n')
|
||||
}
|
||||
}
|
||||
m.insertRunesFromUserInput([]rune(s))
|
||||
}
|
||||
|
||||
// InsertRune inserts a rune at the cursor position.
|
||||
func (m *Model) InsertRune(r rune) {
|
||||
if r == '\n' {
|
||||
m.splitLine(m.row, m.col)
|
||||
m.insertRunesFromUserInput([]rune{r})
|
||||
}
|
||||
|
||||
// insertRunesFromUserInput inserts runes at the current cursor position.
|
||||
func (m *Model) insertRunesFromUserInput(runes []rune) {
|
||||
// Clean up any special characters in the input provided by the
|
||||
// clipboard. This avoids bugs due to e.g. tab characters and
|
||||
// whatnot.
|
||||
runes = m.san().Sanitize(runes)
|
||||
|
||||
var availSpace int
|
||||
if m.CharLimit > 0 {
|
||||
availSpace = m.CharLimit - m.Length()
|
||||
// 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(runes) {
|
||||
runes = runes[:len(runes)-availSpace]
|
||||
}
|
||||
}
|
||||
|
||||
// Split the input into lines.
|
||||
var lines [][]rune
|
||||
lstart := 0
|
||||
for i := 0; i < len(runes); i++ {
|
||||
if runes[i] == '\n' {
|
||||
lines = append(lines, runes[lstart:i])
|
||||
lstart = i + 1
|
||||
}
|
||||
}
|
||||
if lstart < len(runes) {
|
||||
// The last line did not end with a newline character.
|
||||
// Take it now.
|
||||
lines = append(lines, runes[lstart:])
|
||||
}
|
||||
|
||||
// Obey the maximum height limit.
|
||||
if len(m.value)+len(lines)-1 > maxHeight {
|
||||
allowedHeight := max(0, maxHeight-len(m.value)+1)
|
||||
lines = lines[:allowedHeight]
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
// Nothing left to insert.
|
||||
return
|
||||
}
|
||||
|
||||
m.value[m.row] = append(m.value[m.row][:m.col], append([]rune{r}, m.value[m.row][m.col:]...)...)
|
||||
m.col++
|
||||
// Save the reminder of the original line at the current
|
||||
// cursor position.
|
||||
tail := make([]rune, len(m.value[m.row][m.col:]))
|
||||
copy(tail, m.value[m.row][m.col:])
|
||||
|
||||
// Paste the first line at the current cursor position.
|
||||
m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...)
|
||||
m.col += len(lines[0])
|
||||
|
||||
if numExtraLines := len(lines) - 1; numExtraLines > 0 {
|
||||
// Add the new lines.
|
||||
// We try to reuse the slice if there's already space.
|
||||
var newGrid [][]rune
|
||||
if cap(m.value) >= len(m.value)+numExtraLines {
|
||||
// Can reuse the extra space.
|
||||
newGrid = m.value[:len(m.value)+numExtraLines]
|
||||
} else {
|
||||
// No space left; need a new slice.
|
||||
newGrid = make([][]rune, len(m.value)+numExtraLines)
|
||||
copy(newGrid, m.value[:m.row+1])
|
||||
}
|
||||
// Add all the rows that were after the cursor in the original
|
||||
// grid at the end of the new grid.
|
||||
copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
|
||||
m.value = newGrid
|
||||
// Insert all the new lines in the middle.
|
||||
for _, l := range lines[1:] {
|
||||
m.row++
|
||||
m.value[m.row] = l
|
||||
m.col = len(l)
|
||||
}
|
||||
}
|
||||
|
||||
// Finally add the tail at the end of the last line inserted.
|
||||
m.value[m.row] = append(m.value[m.row], tail...)
|
||||
|
||||
m.SetCursor(m.col)
|
||||
}
|
||||
|
||||
// Value returns the value of the text input.
|
||||
@ -326,6 +402,7 @@ func (m *Model) Length() int {
|
||||
for _, row := range m.value {
|
||||
l += rw.StringWidth(string(row))
|
||||
}
|
||||
// We add len(m.value) to include the newline characters.
|
||||
return l + len(m.value) - 1
|
||||
}
|
||||
|
||||
@ -456,50 +533,14 @@ func (m *Model) Reset() {
|
||||
m.SetCursor(0)
|
||||
}
|
||||
|
||||
// handle a clipboard paste event, if supported.
|
||||
func (m *Model) handlePaste(v string) {
|
||||
paste := []rune(v)
|
||||
|
||||
var availSpace int
|
||||
if m.CharLimit > 0 {
|
||||
availSpace = m.CharLimit - m.Length()
|
||||
// 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()
|
||||
}
|
||||
|
||||
// If the char limit's been reached cancel
|
||||
if m.CharLimit > 0 && availSpace <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// If there's not enough space to paste the whole thing cut the pasted
|
||||
// runes down so they'll fit
|
||||
if m.CharLimit > 0 && availSpace < len(paste) {
|
||||
paste = paste[:len(paste)-availSpace]
|
||||
}
|
||||
|
||||
// Stuff before and after the cursor
|
||||
head := m.value[m.row][:m.col]
|
||||
tailSrc := m.value[m.row][m.col:]
|
||||
tail := make([]rune, len(tailSrc))
|
||||
copy(tail, tailSrc)
|
||||
|
||||
// Insert pasted runes
|
||||
for _, r := range paste {
|
||||
head = append(head, r)
|
||||
m.col++
|
||||
if m.CharLimit > 0 {
|
||||
availSpace--
|
||||
if availSpace <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Put it all back together
|
||||
value := append(head, tail...)
|
||||
m.SetValue(string(value))
|
||||
|
||||
// Reset blink state if necessary and run overflow checks
|
||||
m.SetCursor(m.col + len(paste))
|
||||
return m.rsan
|
||||
}
|
||||
|
||||
// deleteBeforeCursor deletes all text before the cursor. Returns whether or
|
||||
@ -936,17 +977,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.transposeLeft()
|
||||
|
||||
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))
|
||||
m.insertRunesFromUserInput(msg.Runes)
|
||||
}
|
||||
|
||||
case pasteMsg:
|
||||
m.handlePaste(string(msg))
|
||||
m.insertRunesFromUserInput([]rune(msg))
|
||||
|
||||
case pasteErrMsg:
|
||||
m.Err = msg
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/cursor"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/runeutil"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
rw "github.com/mattn/go-runewidth"
|
||||
@ -129,6 +130,9 @@ type Model struct {
|
||||
// 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
|
||||
}
|
||||
|
||||
// New creates a new model with default settings.
|
||||
@ -154,8 +158,15 @@ var NewModel = New
|
||||
|
||||
// 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))
|
||||
m.setValueInternal(runes)
|
||||
}
|
||||
|
||||
func (m *Model) setValueInternal(runes []rune) {
|
||||
if m.Validate != nil {
|
||||
if err := m.Validate(s); err != nil {
|
||||
if err := m.Validate(string(runes)); err != nil {
|
||||
m.Err = err
|
||||
return
|
||||
}
|
||||
@ -164,7 +175,6 @@ func (m *Model) SetValue(s string) {
|
||||
empty := len(m.value) == 0
|
||||
m.Err = nil
|
||||
|
||||
runes := []rune(s)
|
||||
if m.CharLimit > 0 && len(runes) > m.CharLimit {
|
||||
m.value = runes[:m.CharLimit]
|
||||
} else {
|
||||
@ -228,24 +238,37 @@ func (m *Model) Reset() {
|
||||
m.SetCursor(0)
|
||||
}
|
||||
|
||||
// handle a clipboard paste event, if supported.
|
||||
func (m *Model) handlePaste(v string) {
|
||||
paste := []rune(v)
|
||||
// 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 m.CharLimit > 0 && availSpace <= 0 {
|
||||
return
|
||||
}
|
||||
// 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 m.CharLimit > 0 && availSpace < len(paste) {
|
||||
paste = paste[:len(paste)-availSpace]
|
||||
// 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[:len(paste)-availSpace]
|
||||
}
|
||||
}
|
||||
|
||||
// Stuff before and after the cursor
|
||||
@ -270,13 +293,11 @@ func (m *Model) handlePaste(v string) {
|
||||
|
||||
// Put it all back together
|
||||
value := append(head, tail...)
|
||||
m.SetValue(string(value))
|
||||
m.setValueInternal(value)
|
||||
|
||||
if m.Err != nil {
|
||||
m.pos = oldPos
|
||||
}
|
||||
|
||||
m.SetCursor(m.pos)
|
||||
}
|
||||
|
||||
// If a max width is defined, perform some logic to treat the visible area
|
||||
@ -555,22 +576,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.KeyMap.DeleteWordForward):
|
||||
m.deleteWordForward()
|
||||
default:
|
||||
// Input a regular character
|
||||
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
|
||||
runes := msg.Runes
|
||||
|
||||
value := make([]rune, len(m.value))
|
||||
copy(value, m.value)
|
||||
value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
|
||||
m.SetValue(string(value))
|
||||
if m.Err == nil {
|
||||
m.SetCursor(m.pos + len(runes))
|
||||
}
|
||||
}
|
||||
// Input one or more regular characters.
|
||||
m.insertRunesFromUserInput(msg.Runes)
|
||||
}
|
||||
|
||||
case pasteMsg:
|
||||
m.handlePaste(string(msg))
|
||||
m.insertRunesFromUserInput([]rune(msg))
|
||||
|
||||
case pasteErrMsg:
|
||||
m.Err = msg
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user