From 0111d9bd614159043995e4c6627d52ec83fa36f4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:22:06 +0100 Subject: [PATCH] Let views receive pasted text in one piece Editable views receive pasted text as one key press per character, so a newline or a tab in the text triggers the keybinding for Enter or Tab. The commit message panel works around this by checking IsPasting in these keybindings. This is fragile, and it gives the panel no way to look at the pasted text as a whole. Add an OnPaste handler to View. For a view that has one, collect the pasted characters, newlines and tabs while the paste lasts, and pass them to the handler when it ends. Drop all other keys of the paste. They are unlikely to occur in pasted text, and dispatching keys like Esc in the middle of a paste would run them as commands. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/gui.go | 38 +++++++++++++++++++++++ pkg/gocui/paste_test.go | 69 +++++++++++++++++++++++++++++++++++++++++ pkg/gocui/view.go | 6 ++++ 3 files changed, 113 insertions(+) create mode 100644 pkg/gocui/paste_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a29eb3192..8c811c5d1 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -190,6 +190,10 @@ type Gui struct { IsPasting bool + // The text pasted so far into pasteView, a view with an OnPaste handler + pasteBuffer strings.Builder + pasteView *View + // If InputEsc is true, when ESC sequence is in the buffer and it doesn't // match any known sequence, ESC means KeyEsc. InputEsc bool @@ -1285,6 +1289,9 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { return g.onFocus(ev) case eventPaste: g.IsPasting = ev.Start + if !ev.Start { + return g.deliverPaste() + } return nil default: return nil @@ -1758,6 +1765,32 @@ func (g *Gui) draw(v *View) { } } +func (g *Gui) collectPastedKey(v *View, key Key) { + if g.pasteView == nil { + g.pasteView = v + } + + switch { + case key.IsPrintable(): + g.pasteBuffer.WriteString(key.Str()) + case key.Equals(NewKeyName(KeyEnter)): + g.pasteBuffer.WriteString("\n") + case key.Equals(NewKeyName(KeyTab)): + g.pasteBuffer.WriteString("\t") + } +} + +func (g *Gui) deliverPaste() error { + v, text := g.pasteView, g.pasteBuffer.String() + g.pasteView = nil + g.pasteBuffer.Reset() + + if v == nil || text == "" { + return nil + } + return v.OnPaste(text) +} + // onKey manages key-press events. A keybinding handler is called when // a key-press or mouse event satisfies a configured keybinding. Furthermore, // currentView's internal buffer is modified if currentView.Editable is true. @@ -1776,6 +1809,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error { ev.Key = NewKeyName(KeyEnter) } + if g.IsPasting && g.currentView != nil && g.currentView.OnPaste != nil { + g.collectPastedKey(g.currentView, ev.Key) + return nil + } + err := g.execKeybindings(g.currentView, ev) if err != nil { return err diff --git a/pkg/gocui/paste_test.go b/pkg/gocui/paste_test.go new file mode 100644 index 000000000..473ca574b --- /dev/null +++ b/pkg/gocui/paste_test.go @@ -0,0 +1,69 @@ +package gocui + +import ( + "testing" + + "github.com/gdamore/tcell/v3" + "github.com/stretchr/testify/assert" +) + +func TestPasteIntoViewWithPasteHandler(t *testing.T) { + g := newTestGui(t) + v, _ := g.SetView("editor", 0, 0, 40, 10, 0) + v.Editable = true + _, err := g.SetCurrentView("editor") + assert.NoError(t, err) + + pasted := []string{} + v.OnPaste = func(text string) error { + pasted = append(pasted, text) + return nil + } + escPressed := false + g.SetKeybinding("editor", NewKeyName(KeyEsc), func(*Gui, *View) error { + escPressed = true + return nil + }) + + for _, tev := range []tcell.Event{ + tcell.NewEventPaste(true), + tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), + tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone), + // Ghostty pastes newlines as ctrl-j + tcell.NewEventKey(tcell.KeyRune, "j", tcell.ModCtrl), + tcell.NewEventKey(tcell.KeyTab, "", tcell.ModNone), + tcell.NewEventKey(tcell.KeyEsc, "", tcell.ModNone), + tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModCtrl), + tcell.NewEventKey(tcell.KeyRune, "b", tcell.ModNone), + tcell.NewEventPaste(false), + } { + ev := gocuiEventFromTcellEvent(tev) + assert.NoError(t, g.handleEvent(&ev)) + } + + assert.Equal(t, []string{"a\n\n\tb"}, pasted) + assert.False(t, escPressed) + assert.Equal(t, "", v.TextArea.GetContent()) + assert.False(t, g.IsPasting) +} + +func TestPasteIntoViewWithoutPasteHandler(t *testing.T) { + g := newTestGui(t) + v, _ := g.SetView("editor", 0, 0, 40, 10, 0) + v.Editable = true + v.Editor = DefaultEditor + _, err := g.SetCurrentView("editor") + assert.NoError(t, err) + + for _, tev := range []tcell.Event{ + tcell.NewEventPaste(true), + tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), + tcell.NewEventKey(tcell.KeyRune, "b", tcell.ModNone), + tcell.NewEventPaste(false), + } { + ev := gocuiEventFromTcellEvent(tev) + assert.NoError(t, g.handleEvent(&ev)) + } + + assert.Equal(t, "ab", v.TextArea.GetContent()) +} diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f16ed14ec..ae4715a9f 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -163,6 +163,12 @@ type View struct { // default. Editor Editor + // OnPaste, if set, receives pasted text in one piece when the paste ends, + // instead of one key press per character. The text consists of the pasted + // printable characters, newlines and tabs; other keys in the paste, such as + // Esc or control characters, are dropped. + OnPaste func(text string) error + // Overwrite enables or disables the overwrite mode of the view. Overwrite bool