Skip to content

Commit

Permalink
feat: bracketed paste
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
knz committed Jan 7, 2023
1 parent 7b1c104 commit a38be3c
Show file tree
Hide file tree
Showing 12 changed files with 224 additions and 50 deletions.
26 changes: 21 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,22 @@ 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 {
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 @@ -595,6 +604,13 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
return 6, MouseMsg(parseX10MouseEvent(b))
}

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

// Detect escape sequence and control characters other than NUL,
// possibly with an escape character in front to mark the Alt
// modifier.
Expand Down
59 changes: 58 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,56 @@ 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, canHaveMoreData bool) (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. What does this
// mean?
if canHaveMoreData {
// There may be more data to read.
// Tell the outer loop we have done a short read and we want more.
return true, 0, nil
}
// There won't be any more input, so we can consider we have our
// event. Assume the bracketed paste extends until the end of
// the input.
idx = len(input)
inputLen = len(bpStart) + idx
}

// 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 @@ -423,23 +423,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
33 changes: 18 additions & 15 deletions nil_renderer.go
Expand Up @@ -2,18 +2,21 @@ package tea

type nilRenderer struct{}

func (n nilRenderer) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(v 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) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(v 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) bracketedPasteActive() bool { return false }
7 changes: 7 additions & 0 deletions options.go
Expand Up @@ -86,6 +86,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 @@ -51,6 +51,10 @@ func TestOptions(t *testing.T) {
exercise(t, WithAltScreen(), withAltScreen)
})

t.Run("bracketed space 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 @@ -85,8 +89,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, withInputTTY} {
p := NewProgram(nil, WithMouseAllMotion(), WithoutBracketedPaste(), WithAltScreen(), WithInputTTY())
for _, opt := range []startupOptions{withMouseAllMotion, withoutBracketedPaste, withAltScreen, withInputTTY} {
if !p.startupOptions.has(opt) {
t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions)
}
Expand Down
17 changes: 14 additions & 3 deletions renderer.go
Expand Up @@ -40,16 +40,27 @@ type renderer interface {
// events if a mouse button is pressed (i.e., drag events).
enableMouseCellMotion()

// DisableMouseCellMotion disables Mouse Cell Motion tracking.
// disableMouseCellMotion disables Mouse Cell Motion tracking.
disableMouseCellMotion()

// EnableMouseAllMotion enables mouse click, release, wheel and motion
// enableMouseAllMotion enables mouse click, release, wheel and motion
// events, regardless of whether a mouse button is pressed. Many modern
// terminals support this, but not all.
enableMouseAllMotion()

// DisableMouseAllMotion disables All Motion mouse tracking.
// disableMouseAllMotion disables All Motion mouse tracking.
disableMouseAllMotion()

// 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",
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",
},
{
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",
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",
},
{
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[?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[?1049l\x1b[?25h",
},
{
name: "mouse_cellmotion",
cmds: []Cmd{EnableMouseCellMotion},
expected: "\x1b[?25l\x1b[?1002hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1002hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l",
},
{
name: "mouse_allmotion",
cmds: []Cmd{EnableMouseAllMotion},
expected: "\x1b[?25l\x1b[?1003hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1003hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l",
},
{
name: "mouse_disable",
cmds: []Cmd{EnableMouseAllMotion, DisableMouse},
expected: "\x1b[?25l\x1b[?1003h\x1b[?1002l\x1b[?1003lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1002l\x1b[?1003lsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l",
},
{
name: "cursor_hide",
cmds: []Cmd{HideCursor},
expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
expected: "\x1b[?25l\x1b[?2004h\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l",
},
{
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",
expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l",
},
{
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",
},
}

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 a38be3c

Please sign in to comment.