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 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-11-12 10:22:50 +01:00
co-authored by Claude Opus 5.5
parent 9d2058fbc4
commit 0111d9bd61
3 changed files with 113 additions and 0 deletions
+38
View File
@@ -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
+69
View File
@@ -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())
}
+6
View File
@@ -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