Skip to content

Commit

Permalink
Support 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 because pasted input may need sanitation in individual widgets.
  • Loading branch information
knz committed Aug 19, 2022
1 parent a7b1d7c commit 8897dd5
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 15 deletions.
129 changes: 119 additions & 10 deletions key.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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[:]...)
}
}
62 changes: 60 additions & 2 deletions key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tea
import (
"bytes"
"testing"
"unicode/utf8"
)

func TestKeyString(t *testing.T) {
Expand Down Expand Up @@ -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": {
Expand All @@ -61,6 +64,7 @@ func TestReadInput(t *testing.T) {
Runes: []rune{'a'},
},
},
false,
},
" ": {
[]byte{' '},
Expand All @@ -70,6 +74,7 @@ func TestReadInput(t *testing.T) {
Runes: []rune{' '},
},
},
false,
},
"ctrl+a": {
[]byte{byte(keySOH)},
Expand All @@ -78,6 +83,7 @@ func TestReadInput(t *testing.T) {
Type: KeyCtrlA,
},
},
false,
},
"alt+a": {
[]byte{byte(0x1b), 'a'},
Expand All @@ -88,6 +94,7 @@ func TestReadInput(t *testing.T) {
Runes: []rune{'a'},
},
},
false,
},
"abcd": {
[]byte{'a', 'b', 'c', 'd'},
Expand All @@ -109,6 +116,7 @@ func TestReadInput(t *testing.T) {
Runes: []rune{'d'},
},
},
false,
},
"up": {
[]byte("\x1b[A"),
Expand All @@ -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)},
Expand All @@ -125,6 +134,7 @@ func TestReadInput(t *testing.T) {
Type: MouseWheelUp,
},
},
false,
},
"shift+tab": {
[]byte{'\x1b', '[', 'Z'},
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
7 changes: 7 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
8 changes: 6 additions & 2 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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)
}
Expand Down
Loading

0 comments on commit 8897dd5

Please sign in to comment.