From 2b46020ca0725219da1a7d7969fa85c486181258 Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Mon, 5 Feb 2024 14:49:09 +0100 Subject: [PATCH] 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 --- examples/simple/testdata/TestApp.golden | 4 +- key.go | 32 +++++++++++++--- key_sequences.go | 50 ++++++++++++++++++++++++- key_test.go | 26 +++++++------ nil_renderer.go | 37 +++++++++--------- options.go | 7 ++++ options_test.go | 8 +++- renderer.go | 11 ++++++ screen.go | 28 ++++++++++++++ screen_test.go | 23 +++++++----- standard_renderer.go | 26 +++++++++++++ tea.go | 20 ++++++++-- tty.go | 1 + 13 files changed, 222 insertions(+), 51 deletions(-) diff --git a/examples/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden index 6b886768a6..52f68184c9 100644 --- a/examples/simple/testdata/TestApp.golden +++ b/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 \ No newline at end of file +[?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/key.go b/key.go index f851490a0d..05d0440bec 100644 --- a/key.go +++ b/key.go @@ -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. diff --git a/key_sequences.go b/key_sequences.go index cc200f8d02..4ba0f79e34 100644 --- a/key_sequences.go +++ b/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 @@ -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) +} diff --git a/key_test.go b/key_test.go index 0b1112a222..830b8cf487 100644 --- a/key_test.go +++ b/key_test.go @@ -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}, }, }, } diff --git a/nil_renderer.go b/nil_renderer.go index 1b1d4409a3..b9edeac134 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -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 } diff --git a/options.go b/options.go index 71e944939a..e78a048740 100644 --- a/options.go +++ b/options.go @@ -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. // diff --git a/options_test.go b/options_test.go index 74e75fe68b..dd51982cdf 100644 --- a/options_test.go +++ b/options_test.go @@ -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) } diff --git a/renderer.go b/renderer.go index 5a3ee3c48d..65c2ae6dfa 100644 --- a/renderer.go +++ b/renderer.go @@ -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. diff --git a/screen.go b/screen.go index d064222fa0..b34af56d7b 100644 --- a/screen.go +++ b/screen.go @@ -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. // diff --git a/screen_test.go b/screen_test.go index a6610a647d..907a8d9a84 100644 --- a/screen_test.go +++ b/screen_test.go @@ -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()) } }) } diff --git a/standard_renderer.go b/standard_renderer.go index 1573a1c278..4fc3698ac8 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -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) { diff --git a/tea.go b/tea.go index f18cb87cf0..b6a92b0ca1 100644 --- a/tea.go +++ b/tea.go @@ -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 diff --git a/tty.go b/tty.go index 01f084d438..015720db6a 100644 --- a/tty.go +++ b/tty.go @@ -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()