diff --git a/runeutil/runeutil.go b/runeutil/runeutil.go new file mode 100644 index 00000000..37279818 --- /dev/null +++ b/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 +} diff --git a/runeutil/runeutil_test.go b/runeutil/runeutil_test.go new file mode 100644 index 00000000..2843e485 --- /dev/null +++ b/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) + } + } +} diff --git a/textarea/textarea.go b/textarea/textarea.go index d5527196..cd425817 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -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() - } - - // 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 @@ -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 diff --git a/textinput/textinput.go b/textinput/textinput.go index 2cae312b..bfa70093 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -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