feat: bracketed paste (#397)

* 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>
This commit is contained in:
Raphael 'kena' Poss 2024-02-05 14:49:09 +01:00 committed by GitHub
parent ab7e5ea8b2
commit 2b46020ca0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 222 additions and 51 deletions

View File

@ -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
key.go
View File

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"regexp"
"strings"
"unicode/utf8"
)
@ -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
@ -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 ""
}
@ -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
}
// Detect escape sequence and control characters other than NUL,
// possibly with an escape character in front to mark the Alt
// modifier.

View File

@ -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
@ -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)
}

View File

@ -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},
},
},
}

View File

@ -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 }

View File

@ -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.
//

View File

@ -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)
})
@ -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)
}

View File

@ -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.

View File

@ -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.
//

View File

@ -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",
},
}
@ -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())
}
})
}

View File

@ -45,6 +45,9 @@ type standardRenderer struct {
// essentially whether or not we're using the full size of the terminal
altScreenActive bool
// whether or not we're currently using bracketed paste
bpActive bool
// renderer dimensions; usually the size of the window
width int
height int
@ -410,6 +413,29 @@ func (r *standardRenderer) disableMouseSGRMode() {
r.out.DisableMouseExtendedMode()
}
func (r *standardRenderer) enableBracketedPaste() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.EnableBracketedPaste()
r.bpActive = true
}
func (r *standardRenderer) disableBracketedPaste() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.DisableBracketedPaste()
r.bpActive = false
}
func (r *standardRenderer) bracketedPasteActive() bool {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.bpActive
}
// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
// renderer.
func (r *standardRenderer) setIgnoredLines(from int, to int) {

20
tea.go
View File

@ -81,7 +81,7 @@ func (i inputType) String() string {
// generally set with ProgramOptions.
//
// The options here are treated as bits.
type startupOptions byte
type startupOptions int16
func (s startupOptions) has(option startupOptions) bool {
return s&option != 0
@ -93,12 +93,12 @@ const (
withMouseAllMotion
withANSICompressor
withoutSignalHandler
// Catching panics is incredibly useful for restoring the terminal to a
// usable state after a panic occurs. When this is set, Bubble Tea will
// recover from panics, print the stack trace, and disable raw mode. This
// feature is on by default.
withoutCatchPanics
withoutBracketedPaste
)
// handlers manages series of channels returned by various processes. It allows
@ -156,6 +156,8 @@ type Program struct {
altScreenWasActive bool
ignoreSignals uint32
bpWasActive bool // was the bracketed paste mode active before releasing the terminal?
// Stores the original reference to stdin for cases where input is not a
// TTY on windows and we've automatically opened CONIN$ to receive input.
// When the program exits this will be restored.
@ -360,6 +362,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
case hideCursorMsg:
p.renderer.hideCursor()
case enableBracketedPasteMsg:
p.renderer.enableBracketedPaste()
case disableBracketedPasteMsg:
p.renderer.disableBracketedPaste()
case execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
@ -496,6 +504,9 @@ func (p *Program) Run() (Model, error) {
if p.startupOptions&withAltScreen != 0 {
p.renderer.enterAltScreen()
}
if p.startupOptions&withoutBracketedPaste == 0 {
p.renderer.enableBracketedPaste()
}
if p.startupOptions&withMouseCellMotion != 0 {
p.renderer.enableMouseCellMotion()
p.renderer.enableMouseSGRMode()
@ -656,6 +667,7 @@ func (p *Program) ReleaseTerminal() error {
}
p.altScreenWasActive = p.renderer.altScreen()
p.bpWasActive = p.renderer.bracketedPasteActive()
return p.restoreTerminalState()
}
@ -671,7 +683,6 @@ func (p *Program) RestoreTerminal() error {
if err := p.initCancelReader(); err != nil {
return err
}
if p.altScreenWasActive {
p.renderer.enterAltScreen()
} else {
@ -681,6 +692,9 @@ func (p *Program) RestoreTerminal() error {
if p.renderer != nil {
p.renderer.start()
}
if p.bpWasActive {
p.renderer.enableBracketedPaste()
}
// If the output is a terminal, it may have been resized while another
// process was at the foreground, in which case we may not have received

1
tty.go
View File

@ -34,6 +34,7 @@ func (p *Program) initTerminal() error {
// Bubble Tea program.
func (p *Program) restoreTerminalState() error {
if p.renderer != nil {
p.renderer.disableBracketedPaste()
p.renderer.showCursor()
p.disableMouse()