Skip to content

Commit

Permalink
feat: bracketed paste in textarea + textinput
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
knz committed Jan 7, 2023
1 parent e78f923 commit 2b31862
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 94 deletions.
100 changes: 100 additions & 0 deletions runeutil/runeutil.go
@@ -0,0 +1,100 @@
// Package runeutil provides a utility function for use in Bubbles
// that can process Key messages containing runes.
package runeutil

import (
"unicode"
"unicode/utf8"
)

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 changes: 44 additions & 0 deletions runeutil/runeutil_test.go
@@ -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)
}
}
}
163 changes: 99 additions & 64 deletions textarea/textarea.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
}

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

// 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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 2b31862

Please sign in to comment.