Configurable right click behavior (#8254)

This MR addresses #4404 following the approach suggested
[here](https://github.com/ghostty-org/ghostty/issues/4404#issuecomment-2708410143).
Implementing the behavior known e.g. from Putty or Windows Terminal.

The following configuration values for `right-click-action` are
provided:
* `context-menu` - Show the context menu.
* `paste` - Paste the contents of the clipboard.
* `copy` - Copy the selected text to the clipboard.
* `copy-and-paste` - Copy the selected text to the clipboard, paste if
nothing is selected.
*  `ignore` - Do nothing, ignore the right-click.

I followed #5935 for getting an idea on where to start. I hope this to
be a temporary solution until "bindable mouse bindings" are introduced.

This is my first time writing Zig code, so I am happy to incorporate any
feedback.

Thank you all very much for your work!
pull/8288/head
Mitchell Hashimoto 2025-08-19 13:21:12 -07:00 committed by GitHub
commit 5745f5048c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 41 deletions

View File

@ -247,6 +247,7 @@ const DerivedConfig = struct {
clipboard_paste_protection: bool, clipboard_paste_protection: bool,
clipboard_paste_bracketed_safe: bool, clipboard_paste_bracketed_safe: bool,
copy_on_select: configpkg.CopyOnSelect, copy_on_select: configpkg.CopyOnSelect,
right_click_action: configpkg.RightClickAction,
confirm_close_surface: configpkg.ConfirmCloseSurface, confirm_close_surface: configpkg.ConfirmCloseSurface,
cursor_click_to_move: bool, cursor_click_to_move: bool,
desktop_notifications: bool, desktop_notifications: bool,
@ -314,6 +315,7 @@ const DerivedConfig = struct {
.clipboard_paste_protection = config.@"clipboard-paste-protection", .clipboard_paste_protection = config.@"clipboard-paste-protection",
.clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe",
.copy_on_select = config.@"copy-on-select", .copy_on_select = config.@"copy-on-select",
.right_click_action = config.@"right-click-action",
.confirm_close_surface = config.@"confirm-close-surface", .confirm_close_surface = config.@"confirm-close-surface",
.cursor_click_to_move = config.@"cursor-click-to-move", .cursor_click_to_move = config.@"cursor-click-to-move",
.desktop_notifications = config.@"desktop-notifications", .desktop_notifications = config.@"desktop-notifications",
@ -1833,6 +1835,32 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard)
}; };
} }
fn copySelectionToClipboards(
self: *Surface,
sel: terminal.Selection,
clipboards: []const apprt.Clipboard,
) void {
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
.trim = self.config.clipboard_trim_trailing_spaces,
}) catch |err| {
log.err("error reading selection string err={}", .{err});
return;
};
defer self.alloc.free(buf);
for (clipboards) |clipboard| self.rt_surface.setClipboardString(
buf,
clipboard,
false,
) catch |err| {
log.err(
"error setting clipboard string clipboard={} err={}",
.{ clipboard, err },
);
};
}
/// Set the selection contents. /// Set the selection contents.
/// ///
/// This must be called with the renderer mutex held. /// This must be called with the renderer mutex held.
@ -1850,33 +1878,12 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
const sel = sel_ orelse return; const sel = sel_ orelse return;
if (prev_) |prev| if (sel.eql(prev)) return; if (prev_) |prev| if (sel.eql(prev)) return;
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
.trim = self.config.clipboard_trim_trailing_spaces,
}) catch |err| {
log.err("error reading selection string err={}", .{err});
return;
};
defer self.alloc.free(buf);
// Set the clipboard. This is not super DRY but it is clear what
// we're doing for each setting without being clever.
switch (self.config.copy_on_select) { switch (self.config.copy_on_select) {
.false => unreachable, // handled above with an early exit .false => unreachable, // handled above with an early exit
// Both standard and selection clipboards are set. // Both standard and selection clipboards are set.
.clipboard => { .clipboard => {
const clipboards: []const apprt.Clipboard = &.{ .standard, .selection }; self.copySelectionToClipboards(sel, &.{ .standard, .selection });
for (clipboards) |clipboard| self.rt_surface.setClipboardString(
buf,
clipboard,
false,
) catch |err| {
log.err(
"error setting clipboard string clipboard={} err={}",
.{ clipboard, err },
);
};
}, },
// The selection clipboard is set if supported, otherwise the standard. // The selection clipboard is set if supported, otherwise the standard.
@ -1885,17 +1892,7 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
.selection .selection
else else
.standard; .standard;
self.copySelectionToClipboards(sel, &.{clipboard});
self.rt_surface.setClipboardString(
buf,
clipboard,
false,
) catch |err| {
log.err(
"error setting clipboard string clipboard={} err={}",
.{ clipboard, err },
);
};
}, },
} }
} }
@ -3582,18 +3579,49 @@ pub fn mouseButtonCallback(
break :pin pin; break :pin pin;
}; };
// If we already have a selection and the selection contains switch (self.config.right_click_action) {
// where we clicked then we don't want to modify the selection. .ignore => {
if (self.io.terminal.screen.selection) |prev_sel| { // Return early to skip clearing the selection.
if (prev_sel.contains(screen, pin)) break :sel; try self.queueRender();
return true;
},
.copy => {
if (self.io.terminal.screen.selection) |sel| {
self.copySelectionToClipboards(sel, &.{.standard});
}
},
.@"copy-or-paste" => {
if (self.io.terminal.screen.selection) |sel| {
self.copySelectionToClipboards(sel, &.{.standard});
} else {
try self.startClipboardRequest(.standard, .paste);
}
},
.paste => {
try self.startClipboardRequest(.standard, .paste);
},
.@"context-menu" => {
// If we already have a selection and the selection contains
// where we clicked then we don't want to modify the selection.
if (self.io.terminal.screen.selection) |prev_sel| {
if (prev_sel.contains(screen, pin)) break :sel;
// The selection doesn't contain our pin, so we create a new // The selection doesn't contain our pin, so we create a new
// word selection where we clicked. // word selection where we clicked.
}
const sel = screen.selectWord(pin) orelse break :sel;
try self.setSelection(sel);
try self.queueRender();
return false;
},
} }
const sel = screen.selectWord(pin) orelse break :sel; try self.setSelection(null);
try self.setSelection(sel);
try self.queueRender(); try self.queueRender();
// Consume the event such that the context menu is not displayed.
return true;
} }
return false; return false;

View File

@ -19,6 +19,7 @@ pub const ClipboardAccess = Config.ClipboardAccess;
pub const Command = Config.Command; pub const Command = Config.Command;
pub const ConfirmCloseSurface = Config.ConfirmCloseSurface; pub const ConfirmCloseSurface = Config.ConfirmCloseSurface;
pub const CopyOnSelect = Config.CopyOnSelect; pub const CopyOnSelect = Config.CopyOnSelect;
pub const RightClickAction = Config.RightClickAction;
pub const CustomShaderAnimation = Config.CustomShaderAnimation; pub const CustomShaderAnimation = Config.CustomShaderAnimation;
pub const FontSyntheticStyle = Config.FontSyntheticStyle; pub const FontSyntheticStyle = Config.FontSyntheticStyle;
pub const FontShapingBreak = Config.FontShapingBreak; pub const FontShapingBreak = Config.FontShapingBreak;

View File

@ -1900,6 +1900,19 @@ keybind: Keybinds = .{},
else => .false, else => .false,
}, },
/// The action to take when the user right-clicks on the terminal surface.
///
/// Valid values:
/// * `context-menu` - Show the context menu.
/// * `paste` - Paste the contents of the clipboard.
/// * `copy` - Copy the selected text to the clipboard.
/// * `copy-or-paste` - If there is a selection, copy the selected text to
/// the clipboard; otherwise, paste the contents of the clipboard.
/// * `ignore` - Do nothing, ignore the right-click.
///
/// The default value is `context-menu`.
@"right-click-action": RightClickAction = .@"context-menu",
/// The time in milliseconds between clicks to consider a click a repeat /// The time in milliseconds between clicks to consider a click a repeat
/// (double, triple, etc.) or an entirely new single click. A value of zero will /// (double, triple, etc.) or an entirely new single click. A value of zero will
/// use a platform-specific default. The default on macOS is determined by the /// use a platform-specific default. The default on macOS is determined by the
@ -6709,6 +6722,25 @@ pub const CopyOnSelect = enum {
clipboard, clipboard,
}; };
/// Options for right-click actions.
pub const RightClickAction = enum {
/// No action is taken on right-click.
ignore,
/// Pastes from the system clipboard.
paste,
/// Copies the selected text to the system clipboard.
copy,
/// Copies the selected text to the system clipboard and
/// pastes the clipboard if no text is selected.
@"copy-or-paste",
/// Shows a context menu with options.
@"context-menu",
};
/// Shell integration values /// Shell integration values
pub const ShellIntegration = enum { pub const ShellIntegration = enum {
none, none,