From 8897dd5b44ecc25291157b0dc35595dfef0776d3 Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Sun, 14 Aug 2022 20:18:20 +0200 Subject: [PATCH] Support 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 because pasted input may need sanitation in individual widgets. --- key.go | 129 ++++++++++++++++++++++++++++++++++++++++++++---- key_test.go | 62 ++++++++++++++++++++++- options.go | 7 +++ options_test.go | 8 ++- tea.go | 71 ++++++++++++++++++++++++++ tty.go | 2 +- 6 files changed, 264 insertions(+), 15 deletions(-) diff --git a/key.go b/key.go index c21037677d..77e87d37af 100644 --- a/key.go +++ b/key.go @@ -1,10 +1,14 @@ package tea import ( + "bytes" "errors" "fmt" "io" + "unicode" "unicode/utf8" + + te "github.com/muesli/termenv" ) // KeyMsg contains information about a keypress. KeyMsgs are always sent to @@ -53,6 +57,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 @@ -67,7 +72,13 @@ func (k Key) String() (str string) { str += "alt+" } if k.Type == KeyRunes { + if k.Paste { + str += "[" + } str += string(k.Runes) + if k.Paste { + str += "]" + } return str } else if s, ok := keyNames[k.Type]; ok { str += s @@ -470,16 +481,30 @@ var hexes = map[string]Key{ "1b4f44": {Type: KeyLeft, Alt: false}, } +const ( + // Bracketed paste sequences. + bpStartSeq = te.CSI + te.StartBracketedPasteSeq + bpEndSeq = te.CSI + te.EndBracketedPasteSeq +) + // readInputs reads keypress and mouse inputs from a TTY and returns messages // containing information about the key or mouse events accordingly. -func readInputs(input io.Reader) ([]Msg, error) { - var buf [256]byte +func readInputs(input io.Reader, bpEnabled bool) ([]Msg, error) { + var inputBuf [256]byte + buf := inputBuf[:] // Read and block numBytes, err := input.Read(buf[:]) if err != nil { return nil, err } + if numBytes == len(buf) { + // This can happen when a large amount of text is suddenly pasted. + buf, numBytes, err = readMore(buf, input) + if err != nil { + return nil, err + } + } // Check if it's a mouse event. For now we're parsing X10-type mouse events // only. @@ -492,38 +517,82 @@ func readInputs(input io.Reader) ([]Msg, error) { return m, nil } - var runeSets [][]rune + type runeSet struct { + bracketedPaste bool + runes []rune + } + var runeSets []runeSet var runes []rune b := buf[:numBytes] // Translate input into runes. In most cases we'll receive exactly one // rune, but there are cases, particularly when an input method editor is // used, where we can receive multiple runes at once. + inBracketedPaste := false for i, w := 0, 0; i < len(b); i += w { r, width := utf8.DecodeRune(b[i:]) if r == utf8.RuneError { return nil, errors.New("could not decode rune") } - if r == '\x1b' && len(runes) > 1 { - // a new key sequence has started - runeSets = append(runeSets, runes) - runes = []rune{} + if r == '\x1b' { + if !bpEnabled { + // Simple, no-bracketed-paste behavior. + if len(runes) > 0 { + runeSets = append(runeSets, runeSet{runes: runes}) + runes = []rune{} + } + } else { + // Bracketed paste enabled. + // If outside of a bp block, look for start sequences. + if !inBracketedPaste { + // End the previous sequence if there was one. + if len(runes) > 0 { + runeSets = append(runeSets, runeSet{runes: runes}) + runes = []rune{} + } + if i+len(bpStartSeq) <= len(b) && bytes.Equal(b[i:i+len(bpStartSeq)], []byte(bpStartSeq)) { + inBracketedPaste = true + w = len(bpStartSeq) + continue + } + } else { + // Currently in a bracketed paste block. + if i+len(bpEndSeq) <= len(b) && bytes.Equal(b[i:i+len(bpEndSeq)], []byte(bpEndSeq)) { + // End of block; create a sequence with the input so far. + runeSets = append(runeSets, runeSet{runes: runes, bracketedPaste: true}) + runes = []rune{} + inBracketedPaste = false + w = len(bpEndSeq) + continue + } + } + } } runes = append(runes, r) w = width } - // add the final set of runes we decoded - runeSets = append(runeSets, runes) + // add the final set of runes we decoded, if any. + if len(runes) > 0 { + runeSets = append(runeSets, runeSet{runes: runes, bracketedPaste: inBracketedPaste}) + } if len(runeSets) == 0 { return nil, errors.New("received 0 runes from input") } var msgs []Msg - for _, runes := range runeSets { + for _, set := range runeSets { + // Is it a literal pasted block? + if set.bracketedPaste { + // Paste the characters as-is. + msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: set.runes, Paste: true})) + continue + } + // Is it a sequence, like an arrow key? + runes := set.runes if k, ok := sequences[string(runes)]; ok { msgs = append(msgs, KeyMsg(k)) continue @@ -565,3 +634,43 @@ func readInputs(input io.Reader) ([]Msg, error) { return msgs, nil } + +// SanitizeRunes removes control characters from runes in a KeyRunes +// message, and optionally replaces newline/carriage return by a +// specified character. +// +// The rune array is modified in-place. The returned slice +// is the original slice shortened after the control characters have been removed. +func SanitizeRunes(runes []rune, replaceNewLine rune) []rune { + var dst int + for src := 0; src < len(runes); src++ { + r := runes[src] + if r == '\r' || r == '\n' { + runes[dst] = replaceNewLine + dst++ + } else if r == utf8.RuneError || unicode.IsControl(r) { + // skip + } else { + // Keep the character. + runes[dst] = runes[src] + dst++ + } + } + return runes[:dst] +} + +// readMore extends the input with additional bytes from the input. +// This is called when there's a spike in input. (e.g. copy-paste) +func readMore(buf []byte, input io.Reader) ([]byte, int, error) { + var inputBuf [256]byte + for { + numBytes, err := input.Read(inputBuf[:]) + if err != nil { + return nil, 0, err + } + if numBytes < len(inputBuf) { + return buf, len(buf), nil + } + buf = append(buf, inputBuf[:]...) + } +} diff --git a/key_test.go b/key_test.go index b74f06b53b..6b3ab4d649 100644 --- a/key_test.go +++ b/key_test.go @@ -3,6 +3,7 @@ package tea import ( "bytes" "testing" + "unicode/utf8" ) func TestKeyString(t *testing.T) { @@ -51,6 +52,8 @@ func TestReadInput(t *testing.T) { type test struct { in []byte out []Msg + + bpEnabled bool } for out, td := range map[string]test{ "a": { @@ -61,6 +64,7 @@ func TestReadInput(t *testing.T) { Runes: []rune{'a'}, }, }, + false, }, " ": { []byte{' '}, @@ -70,6 +74,7 @@ func TestReadInput(t *testing.T) { Runes: []rune{' '}, }, }, + false, }, "ctrl+a": { []byte{byte(keySOH)}, @@ -78,6 +83,7 @@ func TestReadInput(t *testing.T) { Type: KeyCtrlA, }, }, + false, }, "alt+a": { []byte{byte(0x1b), 'a'}, @@ -88,6 +94,7 @@ func TestReadInput(t *testing.T) { Runes: []rune{'a'}, }, }, + false, }, "abcd": { []byte{'a', 'b', 'c', 'd'}, @@ -109,6 +116,7 @@ func TestReadInput(t *testing.T) { Runes: []rune{'d'}, }, }, + false, }, "up": { []byte("\x1b[A"), @@ -117,6 +125,7 @@ func TestReadInput(t *testing.T) { Type: KeyUp, }, }, + false, }, "wheel up": { []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, @@ -125,6 +134,7 @@ func TestReadInput(t *testing.T) { Type: MouseWheelUp, }, }, + false, }, "shift+tab": { []byte{'\x1b', '[', 'Z'}, @@ -133,15 +143,35 @@ func TestReadInput(t *testing.T) { Type: KeyShiftTab, }, }, + false, + }, + "[hello world]": { + []byte("b\x1b[200~hello world\x1b[201~a"), + []Msg{ + KeyMsg{ + Type: KeyRunes, + Runes: []rune{'b'}, + }, + KeyMsg{ + Type: KeyRunes, + Runes: []rune{'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'}, + Paste: true, + }, + KeyMsg{ + Type: KeyRunes, + Runes: []rune{'a'}, + }, + }, + true, }, } { t.Run(out, func(t *testing.T) { - msgs, err := readInputs(bytes.NewReader(td.in)) + msgs, err := readInputs(bytes.NewReader(td.in), td.bpEnabled) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(msgs) != len(td.out) { - t.Fatalf("unexpected message list length") + t.Fatalf("unexpected message list length: expected %d, got %d", len(td.out), len(msgs)) } if len(msgs) == 1 { @@ -165,3 +195,31 @@ func TestReadInput(t *testing.T) { }) } } + +func TestSanitizeRunes(t *testing.T) { + td := []struct { + input, output string + }{ + {"hello", "hello"}, + {"hel\nlo", "helXlo"}, + {"hel\rlo", "helXlo"}, + {"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) + result := SanitizeRunes(runes, 'X') + rs := string(result) + if tc.output != rs { + t.Errorf("%q: expected %q, got %q (%+v)", tc.input, tc.output, rs, result) + } + } +} diff --git a/options.go b/options.go index 09e5ecbd73..43f1bb78ae 100644 --- a/options.go +++ b/options.go @@ -65,6 +65,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. // diff --git a/options_test.go b/options_test.go index 1b77977b75..4e446c947c 100644 --- a/options_test.go +++ b/options_test.go @@ -58,6 +58,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) }) @@ -84,8 +88,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) } diff --git a/tea.go b/tea.go index 59b48f9354..c8f49c37b1 100644 --- a/tea.go +++ b/tea.go @@ -72,6 +72,7 @@ const ( withInputTTY withCustomInput withANSICompressor + withoutBracketedPaste ) // Program is a terminal user interface. @@ -97,6 +98,9 @@ type Program struct { altScreenActive bool altScreenWasActive bool // was the altscreen active before releasing the terminal? + bracketedPasteEnabled bool + bpWasActive bool // was the bracketed paste mode active before releasing the terminal? + // CatchPanics 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 @@ -186,6 +190,34 @@ func ExitAltScreen() Msg { // alternate screen buffer. You can send a exitAltScreenMsg with ExitAltScreen. type exitAltScreenMsg 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{} + // EnableMouseCellMotion is a special command that enables mouse click, // release, and wheel events. Mouse movement events are also captured if // a mouse button is pressed (i.e., drag events). @@ -389,6 +421,9 @@ func (p *Program) StartReturningModel() (Model, error) { if p.startupOptions&withAltScreen != 0 { p.EnterAltScreen() } + if p.startupOptions&withoutBracketedPaste == 0 { + p.EnableBracketedPaste() + } if p.startupOptions&withMouseCellMotion != 0 { p.EnableMouseCellMotion() } else if p.startupOptions&withMouseAllMotion != 0 { @@ -507,6 +542,12 @@ func (p *Program) StartReturningModel() (Model, error) { p.renderer.repaint() p.mtx.Unlock() + case enableBracketedPasteMsg: + p.EnableBracketedPaste() + + case disableBracketedPasteMsg: + p.DisableBracketedPaste() + case enterAltScreenMsg: p.EnterAltScreen() @@ -591,6 +632,7 @@ func (p *Program) shutdown(kill bool) { p.ExitAltScreen() p.DisableMouseCellMotion() p.DisableMouseAllMotion() + p.DisableBracketedPaste() _ = p.restoreTerminalState() } @@ -633,6 +675,28 @@ func (p *Program) ExitAltScreen() { } } +// EnableBracketedPaste enables bracketed space. +func (p *Program) EnableBracketedPaste() { + p.mtx.Lock() + defer p.mtx.Unlock() + if p.bracketedPasteEnabled { + return + } + fmt.Fprintf(p.output, te.CSI+te.EnableBracketedPasteSeq) + p.bracketedPasteEnabled = true +} + +// DisableBracketedPaste disables bracketed space. +func (p *Program) DisableBracketedPaste() { + p.mtx.Lock() + defer p.mtx.Unlock() + if !p.bracketedPasteEnabled { + return + } + fmt.Fprintf(p.output, te.CSI+te.DisableBracketedPasteSeq) + p.bracketedPasteEnabled = false +} + // EnableMouseCellMotion enables mouse click, release, wheel and motion events // if a mouse button is pressed (i.e., drag events). // @@ -679,6 +743,10 @@ func (p *Program) DisableMouseAllMotion() { func (p *Program) ReleaseTerminal() error { p.ignoreSignals = true p.cancelInput() + p.bpWasActive = p.bracketedPasteEnabled + if p.bracketedPasteEnabled { + p.DisableBracketedPaste() + } p.altScreenWasActive = p.altScreenActive if p.altScreenActive { p.ExitAltScreen() @@ -701,6 +769,9 @@ func (p *Program) RestoreTerminal() error { return err } + if p.bpWasActive { + p.EnableBracketedPaste() + } if p.altScreenWasActive { p.EnterAltScreen() } diff --git a/tty.go b/tty.go index 17e508b971..1fd71a6d60 100644 --- a/tty.go +++ b/tty.go @@ -56,7 +56,7 @@ func (p *Program) initCancelReader() error { return } - msgs, err := readInputs(p.cancelReader) + msgs, err := readInputs(p.cancelReader, p.bracketedPasteEnabled) if err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) { p.errs <- err