Skip to content

Commit

Permalink
feat: bracketed paste (#397)
Browse files Browse the repository at this point in the history
* feat: bracketed paste

This introduces support for input via bracketed paste, where escape
characters in the pasted input are not interpreted.

Pasted input are marked as a special field in the KeyMsg. This is
useful because pasted input may need sanitation in individual widgets.

* fix(key): support bracketed paste with short reads

Some terminal emulators feed the bracketed paste data in multiple
chunks, which may not be aligned on a 256 byte boundary. So it's
possible for `input.Read` to return less than 256 bytes read
but while there's still more data to be read to complete a bracketed
paste input.

---------

Co-authored-by: Christian Muehlhaeuser <muesli@gmail.com>
  • Loading branch information
knz and muesli committed Feb 5, 2024
1 parent ab7e5ea commit 2b46020
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 51 deletions.
4 changes: 2 additions & 2 deletions examples/simple/testdata/TestApp.golden
@@ -1,3 +1,3 @@
[?25lHi. This program will exit in 10 seconds. To quit sooner press any key
[?25l[?2004hHi. This program will exit in 10 seconds. To quit sooner press any key
Hi. This program will exit in 9 seconds. To quit sooner press any key.
[?25h[?1002l[?1003l[?1006l
[?2004l[?25h[?1002l[?1003l[?1006l
32 changes: 27 additions & 5 deletions key.go
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"regexp"
"strings"
"unicode/utf8"
)

Expand Down Expand Up @@ -54,6 +55,7 @@ type Key struct {
Type KeyType
Runes []rune
Alt bool
Paste bool
}

// String returns a friendly string representation for a key. It's safe (and
Expand All @@ -63,15 +65,28 @@ type Key struct {
// fmt.Println(k)
// // Output: enter
func (k Key) String() (str string) {
var buf strings.Builder
if k.Alt {
str += "alt+"
buf.WriteString("alt+")
}
if k.Type == KeyRunes {
str += string(k.Runes)
return str
if k.Paste {
// Note: bubbles/keys bindings currently do string compares to
// recognize shortcuts. Since pasted text should never activate
// shortcuts, we need to ensure that the binding code doesn't
// match Key events that result from pastes. We achieve this
// here by enclosing pastes in '[...]' so that the string
// comparison in Matches() fails in that case.
buf.WriteByte('[')
}
buf.WriteString(string(k.Runes))
if k.Paste {
buf.WriteByte(']')
}
return buf.String()
} else if s, ok := keyNames[k.Type]; ok {
str += s
return str
buf.WriteString(s)
return buf.String()
}
return ""
}
Expand Down Expand Up @@ -613,6 +628,13 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
}
}

// Detect bracketed paste.
var foundbp bool
foundbp, w, msg = detectBracketedPaste(b)
if foundbp {
return

Check failure on line 635 in key.go

View workflow job for this annotation

GitHub Actions / lint-soft

naked return in func `detectOneMsg` with 94 lines of code (nakedret)
}

// Detect escape sequence and control characters other than NUL,
// possibly with an escape character in front to mark the Alt
// modifier.
Expand Down
50 changes: 49 additions & 1 deletion key_sequences.go
@@ -1,6 +1,10 @@
package tea

import "sort"
import (
"bytes"
"sort"
"unicode/utf8"
)

// extSequences is used by the map-based algorithm below. It contains
// the sequences plus their alternatives with an escape character
Expand Down Expand Up @@ -69,3 +73,47 @@ func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) {

return false, 0, nil
}

// detectBracketedPaste detects an input pasted while bracketed
// paste mode was enabled.
//
// Note: this function is a no-op if bracketed paste was not enabled
// on the terminal, since in that case we'd never see this
// particular escape sequence.
func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
// Detect the start sequence.
const bpStart = "\x1b[200~"
if len(input) < len(bpStart) || string(input[:len(bpStart)]) != bpStart {
return false, 0, nil
}

// Skip over the start sequence.
input = input[len(bpStart):]

// If we saw the start sequence, then we must have an end sequence
// as well. Find it.
const bpEnd = "\x1b[201~"
idx := bytes.Index(input, []byte(bpEnd))
inputLen := len(bpStart) + idx + len(bpEnd)
if idx == -1 {
// We have encountered the end of the input buffer without seeing
// the marker for the end of the bracketed paste.
// Tell the outer loop we have done a short read and we want more.
return true, 0, nil
}

// The paste is everything in-between.
paste := input[:idx]

// All there is in-between is runes, not to be interpreted further.
k := Key{Type: KeyRunes, Paste: true}
for len(paste) > 0 {
r, w := utf8.DecodeRune(paste)
if r != utf8.RuneError {
k.Runes = append(k.Runes, r)
}
paste = paste[w:]
}

return true, inputLen, KeyMsg(k)
}
26 changes: 14 additions & 12 deletions key_test.go
Expand Up @@ -434,23 +434,25 @@ func TestReadInput(t *testing.T) {
[]byte{'\x1b', '\x1b'},
[]Msg{KeyMsg{Type: KeyEsc, Alt: true}},
},
// Bracketed paste does not work yet.
{"?CSI[50 48 48 126]? a b ?CSI[50 48 49 126]?",
{"[a b] o",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', ' ', 'b',
'\x1b', '[', '2', '0', '1', '~',
'o',
},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune("a b"), Paste: true},
KeyMsg{Type: KeyRunes, Runes: []rune("o")},
},
},
{"[a\x03\nb]",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', '\x03', '\n', 'b',
'\x1b', '[', '2', '0', '1', '~'},
[]Msg{
// What we expect once bracketed paste is recognized properly:
//
// KeyMsg{Type: KeyRunes, Runes: []rune("a b")},
//
// What we get instead (for now):
unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e},
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
KeyMsg{Type: KeySpace, Runes: []rune{' '}},
KeyMsg{Type: KeyRunes, Runes: []rune{'b'}},
unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e},
KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true},
},
},
}
Expand Down
37 changes: 20 additions & 17 deletions nil_renderer.go
Expand Up @@ -2,20 +2,23 @@ package tea

type nilRenderer struct{}

func (n nilRenderer) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(_ string) {}
func (n nilRenderer) repaint() {}
func (n nilRenderer) clearScreen() {}
func (n nilRenderer) altScreen() bool { return false }
func (n nilRenderer) enterAltScreen() {}
func (n nilRenderer) exitAltScreen() {}
func (n nilRenderer) showCursor() {}
func (n nilRenderer) hideCursor() {}
func (n nilRenderer) enableMouseCellMotion() {}
func (n nilRenderer) disableMouseCellMotion() {}
func (n nilRenderer) enableMouseAllMotion() {}
func (n nilRenderer) disableMouseAllMotion() {}
func (n nilRenderer) enableMouseSGRMode() {}
func (n nilRenderer) disableMouseSGRMode() {}
func (n nilRenderer) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(_ string) {}
func (n nilRenderer) repaint() {}
func (n nilRenderer) clearScreen() {}
func (n nilRenderer) altScreen() bool { return false }
func (n nilRenderer) enterAltScreen() {}
func (n nilRenderer) exitAltScreen() {}
func (n nilRenderer) showCursor() {}
func (n nilRenderer) hideCursor() {}
func (n nilRenderer) enableMouseCellMotion() {}
func (n nilRenderer) disableMouseCellMotion() {}
func (n nilRenderer) enableMouseAllMotion() {}
func (n nilRenderer) disableMouseAllMotion() {}
func (n nilRenderer) enableBracketedPaste() {}
func (n nilRenderer) disableBracketedPaste() {}
func (n nilRenderer) enableMouseSGRMode() {}
func (n nilRenderer) disableMouseSGRMode() {}
func (n nilRenderer) bracketedPasteActive() bool { return false }
7 changes: 7 additions & 0 deletions options.go
Expand Up @@ -101,6 +101,13 @@ func WithAltScreen() ProgramOption {
}
}

// WithoutBracketedPaste starts the program with bracketed paste disabled.
func WithoutBracketedPaste() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutBracketedPaste
}
}

// WithMouseCellMotion starts the program with the mouse enabled in "cell
// motion" mode.
//
Expand Down
8 changes: 6 additions & 2 deletions options_test.go
Expand Up @@ -81,6 +81,10 @@ func TestOptions(t *testing.T) {
exercise(t, WithAltScreen(), withAltScreen)
})

t.Run("bracketed paste disabled", func(t *testing.T) {
exercise(t, WithoutBracketedPaste(), withoutBracketedPaste)
})

t.Run("ansi compression", func(t *testing.T) {
exercise(t, WithANSICompressor(), withANSICompressor)
})
Expand Down Expand Up @@ -115,8 +119,8 @@ func TestOptions(t *testing.T) {
})

t.Run("multiple", func(t *testing.T) {
p := NewProgram(nil, WithMouseAllMotion(), WithAltScreen(), WithInputTTY())
for _, opt := range []startupOptions{withMouseAllMotion, withAltScreen} {
p := NewProgram(nil, WithMouseAllMotion(), WithoutBracketedPaste(), WithAltScreen(), WithInputTTY())
for _, opt := range []startupOptions{withMouseAllMotion, withoutBracketedPaste, withAltScreen} {
if !p.startupOptions.has(opt) {
t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions)
}
Expand Down
11 changes: 11 additions & 0 deletions renderer.go
Expand Up @@ -56,6 +56,17 @@ type renderer interface {

// disableMouseSGRMode disables mouse extended mode (SGR).
disableMouseSGRMode()

// enableBracketedPaste enables bracketed paste, where characters
// inside the input are not interpreted when pasted as a whole.
enableBracketedPaste()

// disableBracketedPaste disables bracketed paste.
disableBracketedPaste()

// bracketedPasteActive reports whether bracketed paste mode is
// currently enabled.
bracketedPasteActive() bool
}

// repaintMsg forces a full repaint.
Expand Down
28 changes: 28 additions & 0 deletions screen.go
Expand Up @@ -116,6 +116,34 @@ func ShowCursor() Msg {
// this message with ShowCursor.
type showCursorMsg struct{}

// EnableBracketedPaste is a special command that tells the Bubble Tea program
// to accept bracketed paste input.
//
// Note that bracketed paste will be automatically disabled when the
// program quits.
func EnableBracketedPaste() Msg {
return enableBracketedPasteMsg{}
}

// enableBracketedPasteMsg in an internal message signals that
// bracketed paste should be enabled. You can send an
// enableBracketedPasteMsg with EnableBracketedPaste.
type enableBracketedPasteMsg struct{}

// DisableBracketedPaste is a special command that tells the Bubble Tea program
// to accept bracketed paste input.
//
// Note that bracketed paste will be automatically disabled when the
// program quits.
func DisableBracketedPaste() Msg {
return disableBracketedPasteMsg{}
}

// disableBracketedPasteMsg in an internal message signals that
// bracketed paste should be disabled. You can send an
// disableBracketedPasteMsg with DisableBracketedPaste.
type disableBracketedPasteMsg struct{}

// EnterAltScreen enters the alternate screen buffer, which consumes the entire
// terminal window. ExitAltScreen will return the terminal to its former state.
//
Expand Down
23 changes: 14 additions & 9 deletions screen_test.go
Expand Up @@ -14,42 +14,47 @@ func TestClearMsg(t *testing.T) {
{
name: "clear_screen",
cmds: []Cmd{ClearScreen},
expected: "\x1b[?25l\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "altscreen",
cmds: []Cmd{EnterAltScreen, ExitAltScreen},
expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "altscreen_autoexit",
cmds: []Cmd{EnterAltScreen},
expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h",
},
{
name: "mouse_cellmotion",
cmds: []Cmd{EnableMouseCellMotion},
expected: "\x1b[?25l\x1b[?1002h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "mouse_allmotion",
cmds: []Cmd{EnableMouseAllMotion},
expected: "\x1b[?25l\x1b[?1003h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "mouse_disable",
cmds: []Cmd{EnableMouseAllMotion, DisableMouse},
expected: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006lsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "cursor_hide",
cmds: []Cmd{HideCursor},
expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "cursor_hideshow",
cmds: []Cmd{HideCursor, ShowCursor},
expected: "\x1b[?25l\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
{
name: "bp_stop_start",
cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste},
expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
},
}

Expand All @@ -69,7 +74,7 @@ func TestClearMsg(t *testing.T) {
}

if buf.String() != test.expected {
t.Errorf("expected embedded sequence, got %q", buf.String())
t.Errorf("expected embedded sequence:\n%q\ngot:\n%q", test.expected, buf.String())
}
})
}
Expand Down

0 comments on commit 2b46020

Please sign in to comment.