gtk: pass through keypress when clipboard has no text (#10089)

## Summary

When `paste_from_clipboard` is triggered but the clipboard contains no
text (e.g., an image), the action now returns `false` to indicate it
couldn't be performed. This enables the `performable:` keybind prefix to
work correctly for paste actions.

## Problem

On GTK/Linux, when a user has `keybind = ctrl+v=paste_from_clipboard`
and the clipboard contains an image (not text), pressing Ctrl+V does
nothing. Applications like `opencode` that handle their own clipboard
reading via `wl-paste` never receive the keypress.

## Solution

Make `clipboardRequest` return `bool` to indicate whether the action
could be performed. For paste requests on GTK, synchronously check if
the clipboard contains text formats before starting the async read. When
no text format is available, return `false`.

Users can now use:
```
keybind = performable:ctrl+v=paste_from_clipboard
```

When the clipboard has no text, the keybind is not consumed and Ctrl+V
passes through to the terminal application.

## Changes

- `Surface.startClipboardRequest` now returns `bool`
- `paste_from_clipboard` / `paste_from_selection` actions return the
result
- GTK apprt checks clipboard formats synchronously before async read
- Embedded apprt always returns `true` (can't check synchronously)

## Testing

1. Add `keybind = performable:ctrl+v=paste_from_clipboard` to config
2. Copy an image to clipboard
3. Open an application that handles image paste (e.g., `opencode`)
4. Press Ctrl+V
5. Image pastes successfully (app receives keypress and handles
clipboard itself)

## Disclaimer

Most of the changes is done with Opus 4.5
This commit is contained in:
Mitchell Hashimoto
2025-12-29 09:39:17 -08:00
committed by GitHub
4 changed files with 40 additions and 16 deletions

View File

@@ -1026,7 +1026,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
return;
}
try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
_ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
},
.clipboard_write => |w| switch (w.req) {
@@ -4173,7 +4173,7 @@ pub fn mouseButtonCallback(
.selection
else
.standard;
try self.startClipboardRequest(clipboard, .{ .paste = {} });
_ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
}
// Right-click down selects word for context menus. If the apprt
@@ -4251,7 +4251,7 @@ pub fn mouseButtonCallback(
// request so we need to unlock.
self.renderer_state.mutex.unlock();
defer self.renderer_state.mutex.lock();
try self.startClipboardRequest(.standard, .paste);
_ = try self.startClipboardRequest(.standard, .paste);
// We don't need to clear selection because we didn't have
// one to begin with.
@@ -4266,7 +4266,7 @@ pub fn mouseButtonCallback(
// request so we need to unlock.
self.renderer_state.mutex.unlock();
defer self.renderer_state.mutex.lock();
try self.startClipboardRequest(.standard, .paste);
_ = try self.startClipboardRequest(.standard, .paste);
},
}
@@ -5330,12 +5330,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
return true;
},
.paste_from_clipboard => try self.startClipboardRequest(
.paste_from_clipboard => return try self.startClipboardRequest(
.standard,
.{ .paste = {} },
),
.paste_from_selection => try self.startClipboardRequest(
.paste_from_selection => return try self.startClipboardRequest(
.selection,
.{ .paste = {} },
),
@@ -6049,11 +6049,15 @@ pub fn completeClipboardRequest(
/// This starts a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// Returns true if the request was started, false if it was not (e.g., clipboard
/// doesn't contain text for paste requests). This allows performable keybinds
/// to pass through when the action cannot be performed.
fn startClipboardRequest(
self: *Surface,
loc: apprt.Clipboard,
req: apprt.ClipboardRequest,
) !void {
) !bool {
switch (req) {
.paste => {}, // always allowed
.osc_52_read => if (self.config.clipboard_read == .deny) {
@@ -6061,14 +6065,14 @@ fn startClipboardRequest(
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
.{},
);
return;
return false;
},
// No clipboard write code paths travel through this function
.osc_52_write => unreachable,
}
try self.rt_surface.clipboardRequest(loc, req);
return try self.rt_surface.clipboardRequest(loc, req);
}
fn completeClipboardPaste(

View File

@@ -652,7 +652,7 @@ pub const Surface = struct {
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
) !bool {
// We need to allocate to get a pointer to store our clipboard request
// so that it is stable until the read_clipboard callback and call
// complete_clipboard_request. This sucks but clipboard requests aren't
@@ -667,6 +667,10 @@ pub const Surface = struct {
@intCast(@intFromEnum(clipboard_type)),
state_ptr,
);
// Embedded apprt can't synchronously check clipboard content types,
// so we always return true to indicate the request was started.
return true;
}
fn completeClipboardRequest(

View File

@@ -73,8 +73,8 @@ pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
try self.surface.clipboardRequest(
) !bool {
return try self.surface.clipboardRequest(
clipboard_type,
state,
);

View File

@@ -1666,8 +1666,8 @@ pub const Surface = extern struct {
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
try Clipboard.request(
) !bool {
return try Clipboard.request(
self,
clipboard_type,
state,
@@ -3623,16 +3623,30 @@ const Clipboard = struct {
/// Request data from the clipboard (read the clipboard). This
/// completes asynchronously and will call the `completeClipboardRequest`
/// core surface API when done.
///
/// Returns true if the request was started, false if the clipboard
/// doesn't contain text (allowing performable keybinds to pass through).
pub fn request(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) Allocator.Error!void {
) Allocator.Error!bool {
// Get our requested clipboard
const clipboard = get(
self.private().gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
) orelse return false;
// For paste requests, check if clipboard has text format available.
// This is a synchronous check that allows performable keybinds to
// pass through when the clipboard contains non-text content (e.g., images).
if (state == .paste) {
const formats = clipboard.getFormats();
if (formats.containGtype(gobject.ext.types.string) == 0) {
log.debug("clipboard has no text format, not starting paste request", .{});
return false;
}
}
// Allocate our userdata
const alloc = Application.default().allocator();
@@ -3652,6 +3666,8 @@ const Clipboard = struct {
clipboardReadText,
ud,
);
return true;
}
/// Paste explicit text directly into the surface, regardless of the