diff --git a/key.go b/key.go index 81f1cf0f4d..846772c278 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 "" } @@ -595,6 +610,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. diff --git a/key_sequences.go b/key_sequences.go index cc200f8d02..167432ae39 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,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) +} diff --git a/key_test.go b/key_test.go index ae8643b903..a72b11d017 100644 --- a/key_test.go +++ b/key_test.go @@ -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}, }, }, } diff --git a/nil_renderer.go b/nil_renderer.go index a0226364e7..a0fe2b051e 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -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 } diff --git a/options.go b/options.go index d9ce42c7c2..5fcc578829 100644 --- a/options.go +++ b/options.go @@ -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. // diff --git a/options_test.go b/options_test.go index 7e08f58eee..77f330ea10 100644 --- a/options_test.go +++ b/options_test.go @@ -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) }) @@ -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) } diff --git a/renderer.go b/renderer.go index a6f416277f..608e62d4f5 100644 --- a/renderer.go +++ b/renderer.go @@ -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. diff --git a/screen.go b/screen.go index 899db3d257..b777c3d8b3 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 2f305e3d15..812a05ac51 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", + 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", }, } @@ -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 17b44e2301..85e9c4fae3 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -44,6 +44,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 @@ -381,6 +384,29 @@ func (r *standardRenderer) disableMouseAllMotion() { r.out.DisableMouseAllMotion() } +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 03734bc575..2254c36825 100644 --- a/tea.go +++ b/tea.go @@ -63,7 +63,7 @@ type handlers []chan struct{} // 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 @@ -77,12 +77,12 @@ const ( withCustomInput 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 ) // Program is a terminal user interface. @@ -114,6 +114,8 @@ type Program struct { altScreenWasActive bool ignoreSignals bool + 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. @@ -296,6 +298,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) @@ -401,6 +409,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() } else if p.startupOptions&withMouseAllMotion != 0 { @@ -546,6 +557,7 @@ func (p *Program) ReleaseTerminal() error { p.waitForReadLoop() p.altScreenWasActive = p.renderer.altScreen() + p.bpWasActive = p.renderer.bracketedPasteActive() return p.restoreTerminalState() } @@ -561,13 +573,15 @@ func (p *Program) RestoreTerminal() error { if err := p.initCancelReader(); err != nil { return err } - if p.altScreenWasActive { p.renderer.enterAltScreen() } else { // entering alt screen already causes a repaint. go p.Send(repaintMsg{}) } + 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 715d542356..0184cfb1d0 100644 --- a/tty.go +++ b/tty.go @@ -33,6 +33,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.renderer.disableMouseCellMotion() p.renderer.disableMouseAllMotion()