Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bracketed paste #397

Merged
merged 3 commits into from Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@
"fmt"
"io"
"regexp"
"strings"
"unicode/utf8"
)

Expand Down Expand Up @@ -54,6 +55,7 @@
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 @@
// 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 @@
}
}

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