Fix mouse interactions in terminal.

This commit is contained in:
Greyson Parrelli
2026-08-13 11:46:48 -04:00
parent 71b3c93521
commit 2daeb6125f
4 changed files with 669 additions and 70 deletions
+27 -1
View File
@@ -113,7 +113,7 @@ Window.zig sidebar + GtkStack of views, tab management, shortcuts
View.zig one tab's content: its panes, their layout, and drag handling
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
Pane.zig content plus its header, drag source, and drop target
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection
Browser.zig WebKitWebView plus a nav/address bar and a find bar
webkit.zig hand-written bindings for the WebKitGTK calls we make
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
@@ -403,6 +403,7 @@ in principle, but a terminal grid is small.
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) |
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
| `Ctrl+Shift+C` | copy the terminal selection |
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) |
@@ -418,6 +419,31 @@ competes with the content's own mouse handling.
Both add-pane shortcuts are also buttons in every pane header, which is how you
open a web view without remembering the chord.
## The mouse in a terminal
A terminal's pointer has two possible owners, and which one has it depends on
what is running.
Ordinarily it is the window's. Dragging selects text, double-click takes a
word and triple-click a line, `Ctrl` while dragging selects a rectangle, and
the wheel moves through the scrollback. Selecting fills the primary selection,
so middle-click pastes what was last highlighted; `Ctrl+Shift+C` puts it on the
clipboard proper.
But a full-screen program can ask for the mouse itself (DEC modes 1000-1003),
and from then on clicks, drags and wheel notches are encoded and handed
straight to it. That is what makes a TUI's buttons clickable and what makes
Claude Code scroll. While a program holds the pointer the cursor goes back to
an arrow, as a reminder that a drag will not select anything. **Holding shift
takes it back** for the length of that drag, so there is always a way to copy
text out of a full-screen application.
One case sits between the two. Pagers — `less`, `man`, anything built on them —
run on the alternate screen, which has no scrollback for the wheel to move
through, and most of them never ask for the mouse. There the wheel becomes
arrow keys (DEC mode 1007), which is what those programs are waiting for
anyway.
## Zoom
A split that was the right shape for watching two things at once is usually the
+613 -16
View File
@@ -9,6 +9,8 @@
const std = @import("std");
const cairo = @import("cairo");
const gdk = @import("gdk");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const pango = @import("pango");
@@ -30,6 +32,32 @@ const font_spec = "monospace 11";
/// Padding between the grid and the widget edge, in pixels.
const pad: f64 = 8;
const pad_px: u32 = @intFromFloat(pad);
/// Rows the viewport moves per wheel notch. Three is the conventional feel,
/// and it is also how many arrow keys a notch turns into for a pager.
const scroll_rows: f64 = 3;
/// How long after a click a second one still counts as a double-click.
/// GTK's own default for `gtk-double-click-time`.
const click_interval_ns: u64 = 400 * std.time.ns_per_ms;
/// Codepoints that end a word for double-click selection. Ghostty's default
/// set, which is tuned for what one actually double-clicks in a terminal:
/// paths and identifiers stay whole, shell punctuation does not. The leading
/// NUL is the empty cell, which always ends a word.
const word_boundaries = codepoints("\x00 \t'\"│`|:;,()[]{}<>$");
fn codepoints(comptime s: []const u8) []const u21 {
comptime {
var out: [s.len]u21 = undefined;
var n: usize = 0;
var it: std.unicode.Utf8Iterator = .{ .bytes = s, .i = 0 };
while (it.nextCodepoint()) |cp| : (n += 1) out[n] = cp;
const final = out[0..n].*;
return &final;
}
}
alloc: std.mem.Allocator,
session: *Session,
@@ -42,6 +70,16 @@ cell_w: f64 = 8,
cell_h: f64 = 16,
ascent: f64 = 12,
/// Pointer state. GTK reports events one at a time, but a mouse report has to
/// carry position, button and modifiers together, so what the current event
/// doesn't say has to be remembered from the ones before it.
mouse: Mouse = .{},
/// Click counting and drag anchoring for text selection. Owned by
/// libghostty-vt so that double-click-selects-word and friends behave the
/// way they do in Ghostty rather than the way I'd guess they should.
gesture: vt.SelectionGesture = .init,
/// Scratch buffer for building the UTF-8 of a single text run.
run_buf: std.ArrayListUnmanaged(u8) = .empty,
@@ -69,6 +107,62 @@ pub const Options = Session.Options;
/// What the child is doing. Reported by the session; passed straight through.
pub const Status = Session.Status;
/// Which clipboard a copy goes to. X11 keeps two: the one Ctrl+C fills, and
/// the one that middle-click pastes from, which selecting text fills on its
/// own. Wayland and GTK preserve the distinction.
pub const Clipboard = enum { standard, primary };
/// Everything about the pointer that survives between GTK events.
const Mouse = struct {
/// Last known position, in widget pixels.
x: f64 = 0,
y: f64 = 0,
/// Modifiers as of the last event we saw.
mods: vt.input.KeyMods = .{},
/// Which buttons are held, indexed by the button's ordinal. Motion
/// reports have to name the button being dragged, and the encoder needs
/// to know whether anything at all is down to decide what to do with
/// events that leave the widget.
down: [12]bool = @splat(false),
/// The last cell reported to the child. The encoder uses it to drop
/// motion that hasn't crossed into a new cell, which is most of it.
last_cell: ?vt.Coordinate = null,
/// Wheel movement not yet worth a whole row. A trackpad sends fractions
/// of a notch, and without this they would all round to nothing.
pending_scroll: f64 = 0,
/// Whether shift was held when the button went down. Latched for the
/// length of the drag rather than read live, so that letting go of shift
/// halfway through a selection doesn't hand the rest of it to a program
/// that is tracking the mouse.
override: bool = false,
fn anyDown(self: Mouse) bool {
return std.mem.indexOfScalar(bool, &self.down, true) != null;
}
/// The button to attribute a motion event to, or null when the pointer is
/// just moving. Lowest-numbered wins, which is what every other terminal
/// does when two buttons are held at once.
fn dragging(self: Mouse) ?vt.input.MouseButton {
const i = std.mem.indexOfScalar(bool, &self.down, true) orelse return null;
return @enumFromInt(@as(c_int, @intCast(i)));
}
fn set(self: *Mouse, button: vt.input.MouseButton, held: bool) void {
const i: usize = @intCast(@intFromEnum(button));
if (i < self.down.len) self.down[i] = held;
}
};
/// The renderer geometry the mouse encoder works in. Named off the encoder's
/// own field so this can't drift from the type it has to be.
const SurfaceSize = @FieldType(vt.input.MouseEncodeOptions, "size");
pub fn create(
alloc: std.mem.Allocator,
opts: Options,
@@ -107,6 +201,7 @@ pub fn create(
errdefer self.session.destroy();
const w = area.as(gtk.Widget);
area.as(gobject.Object).setData(self_key, self);
w.setHexpand(1);
w.setVexpand(1);
// Without this the drawing area can never hold keyboard focus, and all
@@ -114,6 +209,10 @@ pub fn create(
w.setFocusable(1);
w.setCanFocus(1);
// An I-beam over the grid, the way it is over any other text. Programs
// that take the mouse for themselves get the arrow back — see `onMotion`.
w.setCursorFromName("text");
area.setDrawFunc(&drawFunc, self, null);
_ = gtk.DrawingArea.signals.resize.connect(area, *Terminal, &onResize, self, .{});
@@ -137,17 +236,37 @@ pub fn create(
);
w.addController(scroll.as(gtk.EventController));
// Clicking the terminal should focus it.
// Button 0 means every button rather than the left one alone: the middle
// button pastes, and a program that has asked for mouse events wants to
// hear about all of them.
const click = gtk.GestureClick.new();
click.as(gtk.GestureSingle).setButton(0);
_ = gtk.GestureClick.signals.pressed.connect(
click,
*Terminal,
&onClick,
&onPressed,
self,
.{},
);
_ = gtk.GestureClick.signals.released.connect(
click,
*Terminal,
&onReleased,
self,
.{},
);
w.addController(click.as(gtk.EventController));
const motion = gtk.EventControllerMotion.new();
_ = gtk.EventControllerMotion.signals.motion.connect(
motion,
*Terminal,
&onMotion,
self,
.{},
);
w.addController(motion.as(gtk.EventController));
// Watching the property rather than just the click gesture means focus
// taken programmatically or by keyboard navigation is reported too.
_ = gobject.Object.signals.notify.connect(
@@ -162,6 +281,13 @@ pub fn create(
}
pub fn destroy(self: *Terminal) void {
// A clipboard read still in flight resolves through this link; cutting it
// is what makes closing a pane mid-paste harmless.
self.area.as(gobject.Object).setData(self_key, null);
// Before the session: the gesture holds a pin tracked by the terminal's
// page list, and releasing it afterwards would be a use-after-free.
self.gesture.deinit(&self.session.term);
self.session.destroy();
self.run_buf.deinit(self.alloc);
self.font.free();
@@ -241,16 +367,202 @@ fn onResize(_: *gtk.DrawingArea, width: c_int, height: c_int, self: *Terminal) c
}
// -------------------------------------------------------------------------
// Input
// Mouse
//
// A terminal's pointer belongs to whichever of two parties has claimed it.
// Most of the time it is ours: dragging selects text, the wheel moves the
// scrollback. But a program can turn on mouse tracking (DEC modes 1000-1003),
// and from then on it wants the raw events itself — that is how a pager
// scrolls, how a TUI's buttons work, and how Claude Code's own scrolling
// works. Everything below is written around that split, and `mouseReporting`
// is the line between the two halves.
//
// Holding shift takes the pointer back from a program that has claimed it, so
// there is always a way to select text out of a full-screen application.
fn onClick(
_: *gtk.GestureClick,
/// True when the program running here has asked to receive mouse events.
fn mouseReporting(self: *Terminal) bool {
return self.session.term.flags.mouse_event != .none;
}
/// The renderer geometry the mouse encoder needs to turn a pixel position
/// into a cell. Read from the widget rather than cached: it is only wanted
/// while handling an event, by which point GTK has already allocated us.
fn surfaceSize(self: *Terminal) SurfaceSize {
const w = self.widget();
return .{
.screen = .{
.width = @intCast(@max(0, w.getWidth())),
.height = @intCast(@max(0, w.getHeight())),
},
.cell = .{
.width = @intFromFloat(self.cell_w),
.height = @intFromFloat(self.cell_h),
},
.padding = .{
.top = pad_px,
.bottom = pad_px,
.left = pad_px,
.right = pad_px,
},
};
}
/// Send one mouse event to the child, in whichever of the four wire formats
/// it turned on. Silently does nothing when the program isn't listening, or
/// when this particular event isn't one the active mode reports.
fn mouseReport(
self: *Terminal,
button: ?vt.input.MouseButton,
action: vt.input.MouseAction,
) void {
if (!self.mouseReporting()) return;
const term = &self.session.term;
const opts: vt.input.MouseEncodeOptions = .{
.event = term.flags.mouse_event,
.format = term.flags.mouse_format,
.size = self.surfaceSize(),
.any_button_pressed = self.mouse.anyDown(),
.last_cell = &self.mouse.last_cell,
};
// The longest encoding is SGR-pixels with four-digit coordinates, well
// inside this.
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
vt.input.encodeMouse(&writer, .{
.action = action,
.button = button,
.mods = self.mouse.mods,
.pos = .{
.x = @floatCast(self.mouse.x),
.y = @floatCast(self.mouse.y),
},
}, opts) catch |err| {
std.log.warn("mouse encode failed: {s}", .{@errorName(err)});
return;
};
const encoded = writer.buffered();
if (encoded.len == 0) return;
self.session.write(encoded);
}
/// The libghostty button for a GDK button number. GTK4 delivers the wheel
/// through the scroll controller rather than as buttons 4 and 5, so the only
/// extras here are the side buttons.
fn buttonFromGdk(n: c_uint) ?vt.input.MouseButton {
return switch (n) {
1 => .left,
2 => .middle,
3 => .right,
8 => .eight,
9 => .nine,
else => null,
};
}
/// Record where the pointer is and what is held down. Every entry point does
/// this first, because the encoder reads position and modifiers off the
/// stored state rather than off the event.
fn trackPointer(self: *Terminal, ctrl: *gtk.EventController, x: f64, y: f64) void {
self.mouse.x = x;
self.mouse.y = y;
self.mouse.mods = keymap.translateMods(ctrl.getCurrentEventState());
}
/// True when the user is overriding a program's claim on the pointer to
/// select text out of it. Mid-drag that is whatever was decided at press
/// time; otherwise it is simply whether shift is down now.
fn shiftOverride(self: *Terminal) bool {
return if (self.mouse.anyDown()) self.mouse.override else self.mouse.mods.shift;
}
fn onPressed(
click: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
x: f64,
y: f64,
self: *Terminal,
) callconv(.c) void {
self.grabFocus();
const ctrl = click.as(gtk.EventController);
self.trackPointer(ctrl, x, y);
const button = buttonFromGdk(click.as(gtk.GestureSingle).getCurrentButton()) orelse return;
if (!self.mouse.anyDown()) self.mouse.override = self.mouse.mods.shift;
self.mouse.set(button, true);
if (self.mouseReporting() and !self.shiftOverride()) {
// A program that is tracking the mouse draws its own idea of a
// selection; ours would be a second, stale one on top of it.
self.clearSelection();
self.mouseReport(button, .press);
return;
}
switch (button) {
// Middle-click pastes what was last selected, anywhere. This is the
// X11 convention and it long predates the clipboard proper.
.middle => self.pasteFrom(.primary),
.left => self.selectPress(x, y),
else => {},
}
}
fn onReleased(
click: *gtk.GestureClick,
_: c_int,
x: f64,
y: f64,
self: *Terminal,
) callconv(.c) void {
const ctrl = click.as(gtk.EventController);
self.trackPointer(ctrl, x, y);
const button = buttonFromGdk(click.as(gtk.GestureSingle).getCurrentButton()) orelse return;
// Read the override before clearing the button: with nothing held it
// falls back to live shift, which is not what this release belongs to.
const override = self.shiftOverride();
self.mouse.set(button, false);
if (self.mouseReporting() and !override) {
self.mouseReport(button, .release);
return;
}
if (button == .left) self.selectRelease(x, y);
}
fn onMotion(
motion: *gtk.EventControllerMotion,
x: f64,
y: f64,
self: *Terminal,
) callconv(.c) void {
const ctrl = motion.as(gtk.EventController);
self.trackPointer(ctrl, x, y);
const theirs = self.mouseReporting() and !self.shiftOverride();
// An I-beam invites a selection that a program tracking the mouse isn't
// going to give you, so hand the pointer back its ordinary arrow while
// one has the grid. Cheap enough to set on every motion; GTK compares.
self.widget().setCursorFromName(if (theirs) "default" else "text");
if (theirs) {
// The encoder decides whether the active mode wants motion at all,
// and drops anything still inside the last reported cell.
self.mouseReport(self.mouse.dragging(), .motion);
return;
}
if (self.mouse.down[@intFromEnum(vt.input.MouseButton.left)]) {
self.selectDrag(x, y);
}
}
fn onScroll(
@@ -259,15 +571,233 @@ fn onScroll(
dy: f64,
self: *Terminal,
) callconv(.c) c_int {
// Three rows per notch matches the conventional feel.
const delta: isize = @intFromFloat(dy * 3);
if (delta == 0) return 0;
if (dy == 0) return 0;
self.session.term.screens.active.pages.scroll(.{ .delta_row = delta });
// A wheel notch arrives as ±1, a trackpad as a stream of fractions.
// Accumulating means a slow trackpad drag still eventually moves a row
// instead of rounding away to nothing every time.
self.mouse.pending_scroll += dy * scroll_rows;
const rows: isize = @intFromFloat(@trunc(self.mouse.pending_scroll));
self.mouse.pending_scroll -= @floatFromInt(rows);
if (rows == 0) return 1;
const term = &self.session.term;
// The program is tracking the mouse, so the wheel is theirs too: buttons
// four and five are what the wheel is called on the wire.
if (self.mouseReporting() and !self.shiftOverride()) {
self.clearSelection();
const button: vt.input.MouseButton = if (rows < 0) .four else .five;
for (0..@abs(rows)) |_| self.mouseReport(button, .press);
return 1;
}
// Alternate scroll. The alternate screen has no scrollback to move, so a
// full-screen program that hasn't asked for mouse events gets arrow keys
// instead — which is exactly what makes the wheel scroll in `less`, `man`
// and everything else built on a pager.
if (term.screens.active_key == .alternate and
term.modes.get(.mouse_alternate_scroll))
{
const seq = if (term.modes.get(.cursor_keys))
(if (rows < 0) "\x1bOA" else "\x1bOB")
else
(if (rows < 0) "\x1b[A" else "\x1b[B");
for (0..@abs(rows)) |_| self.session.write(seq);
return 1;
}
// Ours: move the viewport through the scrollback. The selection is left
// alone, because its endpoints are pinned to the text rather than to the
// screen and so they scroll with it.
term.screens.active.pages.scroll(.{ .delta_row = rows });
self.area.as(gtk.Widget).queueDraw();
return 1;
}
// -------------------------------------------------------------------------
// Selection
/// The cell under a widget-space point, as a pin into the active screen.
/// Positions outside the grid clamp to its edge rather than failing, so a
/// drag that runs off the side still selects to the end of the line.
fn pinAt(self: *Terminal, x: f64, y: f64) ?vt.Pin {
const term = &self.session.term;
const max_col: f64 = @floatFromInt(term.cols - 1);
const max_row: f64 = @floatFromInt(term.rows - 1);
const col: u16 = @intFromFloat(@min(@max(0.0, x - pad) / self.cell_w, max_col));
const row: u16 = @intFromFloat(@min(@max(0.0, y - pad) / self.cell_h, max_row));
return term.screens.active.pages.pin(.{ .viewport = .{ .x = col, .y = row } });
}
/// The gesture wants a timestamp to tell a double-click from two clicks.
/// GLib's monotonic clock is the one GTK itself times clicks against.
fn now() std.Io.Timestamp {
return .{ .nanoseconds = @as(i96, glib.getMonotonicTime()) * std.time.ns_per_us };
}
fn selectPress(self: *Terminal, x: f64, y: f64) void {
const term = &self.session.term;
const pin = self.pinAt(x, y) orelse return;
// The gesture counts the clicks and picks the behaviour: one selects by
// cell, two by word, three by line.
const sel = self.gesture.press(term, .{
.time = now(),
.pin = pin,
.xpos = x,
.ypos = y,
.max_distance = self.cell_w,
.repeat_interval = click_interval_ns,
.word_boundary_codepoints = word_boundaries,
}) catch |err| {
std.log.warn("selection press failed: {s}", .{@errorName(err)});
return;
};
// A plain single click returns nothing: it is the start of a drag, and
// until the drag happens its only effect is to drop what was selected.
self.applySelection(sel);
}
fn selectDrag(self: *Terminal, x: f64, y: f64) void {
const term = &self.session.term;
const pin = self.pinAt(x, y) orelse return;
const sel = self.gesture.drag(term, .{
.pin = pin,
.xpos = x,
.ypos = y,
.rectangle = self.mouse.mods.ctrl,
.word_boundary_codepoints = word_boundaries,
.geometry = .{
.columns = term.cols,
.cell_width = @intFromFloat(self.cell_w),
.padding_left = pad_px,
.screen_height = @intCast(@max(0, self.widget().getHeight())),
},
}) orelse return;
self.applySelection(sel);
}
fn selectRelease(self: *Terminal, x: f64, y: f64) void {
self.gesture.release(&self.session.term, .{ .pin = self.pinAt(x, y) });
// Selecting fills the primary clipboard, so middle-click pastes what was
// just highlighted without any explicit copy. The standard clipboard is
// left for Ctrl+Shift+C, which is the only thing users expect to disturb
// what they last copied.
_ = self.copySelection(.primary);
}
fn applySelection(self: *Terminal, sel: ?vt.Selection) void {
const screen = self.session.term.screens.active;
if (sel == null and screen.selection == null) return;
screen.select(sel) catch |err| {
std.log.warn("selection failed: {s}", .{@errorName(err)});
return;
};
self.area.as(gtk.Widget).queueDraw();
}
fn clearSelection(self: *Terminal) void {
self.applySelection(null);
}
/// Put the selected text on a clipboard. Returns false when there was
/// nothing selected, so a copy shortcut can decline the key rather than
/// swallow it.
pub fn copySelection(self: *Terminal, which: Clipboard) bool {
const screen = self.session.term.screens.active;
const sel = screen.selection orelse return false;
const text = screen.selectionString(self.alloc, .{ .sel = sel }) catch |err| {
std.log.warn("selection copy failed: {s}", .{@errorName(err)});
return false;
};
defer self.alloc.free(text);
if (text.len == 0) return false;
const w = self.widget();
const clipboard = switch (which) {
.standard => w.getClipboard(),
.primary => w.getPrimaryClipboard(),
};
clipboard.setText(text.ptr);
return true;
}
/// Read a clipboard and type it into the child. GTK4's clipboard API is
/// asynchronous, so this finishes on a later main loop turn — by which point
/// the pane may have been closed. The round trip therefore carries the
/// widget, reffed to keep it alive, and looks the terminal back up from it at
/// completion; `destroy` clears that link, so a paste into a pane that has
/// gone away resolves to nothing instead of to freed memory.
pub fn pasteFrom(self: *Terminal, which: Clipboard) void {
const w = self.widget();
const clipboard = switch (which) {
.standard => w.getClipboard(),
.primary => w.getPrimaryClipboard(),
};
const obj = w.as(gobject.Object);
_ = obj.ref();
clipboard.readTextAsync(null, &onPasteReady, obj);
}
/// Key the terminal is stored under on its own widget. See `pasteFrom`.
const self_key = "playpen-terminal";
fn onPasteReady(
source: ?*gobject.Object,
result: *gio.AsyncResult,
data: ?*anyopaque,
) callconv(.c) void {
const obj: *gobject.Object = @ptrCast(@alignCast(data.?));
defer obj.unref();
const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?));
var err: ?*glib.Error = null;
const text = clipboard.readTextFinish(result, &err) orelse {
if (err) |e| {
std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"});
e.free();
}
return;
};
defer glib.free(text);
const self: *Terminal = @ptrCast(@alignCast(obj.getData(self_key) orelse return));
self.paste(std.mem.span(text));
}
/// Type text into the child as though it had been pasted, bracketing it if
/// the program asked for that.
pub fn paste(self: *Terminal, text: []const u8) void {
const opts: vt.input.PasteOptions = .fromTerminal(&self.session.term);
// Refuse pastes containing control characters that would execute on
// arrival (a newline in unbracketed mode runs the command immediately).
if (!vt.input.isSafePaste(text)) {
std.log.warn("refusing unsafe paste", .{});
return;
}
const parts = vt.input.encodePaste(text, opts) catch |err| {
std.log.warn("paste encode failed: {s}", .{@errorName(err)});
return;
};
for (parts) |part| self.session.write(part);
}
// -------------------------------------------------------------------------
// Keyboard
fn onKeyPressed(
_: *gtk.EventControllerKey,
keyval: c_uint,
@@ -314,8 +844,10 @@ fn onKeyPressed(
// encode to nothing. Let GTK keep processing them.
if (encoded.len == 0) return 0;
// Typing should always snap the view back to the prompt.
// Typing should always snap the view back to the prompt, and a highlight
// left over from before you started typing is only in the way.
self.session.term.screens.active.pages.scroll(.active);
self.clearSelection();
self.session.write(encoded);
self.on_input(self.ctx);
self.area.as(gtk.Widget).queueDraw();
@@ -343,6 +875,53 @@ const Appearance = struct {
}
};
/// The selection flattened to absolute screen coordinates.
///
/// `Selection.contains` walks the page list for the selection's two ends on
/// every call, which is fine for the occasional hit test and ruinous for one
/// per cell per frame. Resolving the ends once and comparing rows against
/// them costs one page-list walk per row instead.
const SelectionBounds = struct {
tl: vt.Coordinate,
br: vt.Coordinate,
rectangle: bool,
/// The inclusive column range the selection covers on the row at absolute
/// screen row `y`, or null if the row is outside the selection.
fn span(self: SelectionBounds, y: u32, cols: usize) ?[2]usize {
if (y < self.tl.y or y > self.br.y) return null;
if (self.rectangle) return .{ self.tl.x, self.br.x };
return .{
if (y == self.tl.y) self.tl.x else 0,
if (y == self.br.y) self.br.x else cols -| 1,
};
}
};
fn inSpan(span: ?[2]usize, x: usize) bool {
const s = span orelse return false;
return x >= s[0] and x <= s[1];
}
/// A cell's appearance with the selection highlight applied on top. Kept
/// apart from `appearance` so that what the selection overrides can't be
/// mistaken for something the program running here asked for.
fn styled(
pin: vt.Pin,
cell: *const vt.Cell,
term: *vt.Terminal,
default_fg: theme.Rgb,
default_bg: theme.Rgb,
selected: bool,
) Appearance {
var look = appearance(pin, cell, term, default_fg, default_bg);
if (selected) {
look.fg = theme.selectionFg();
look.bg = theme.selectionBg();
}
return look;
}
fn drawFunc(
_: *gtk.DrawingArea,
cr: *cairo.Context,
@@ -381,20 +960,38 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
defer layout.unref();
layout.setFontDescription(self.font);
const bounds: ?SelectionBounds = bounds: {
const sel = screen.selection orelse break :bounds null;
const tl = screen.pages.pointFromPin(.screen, sel.topLeft(screen)) orelse
break :bounds null;
const br = screen.pages.pointFromPin(.screen, sel.bottomRight(screen)) orelse
break :bounds null;
break :bounds .{
.tl = tl.screen,
.br = br.screen,
.rectangle = sel.rectangle,
};
};
var y: u16 = 0;
while (y < term.rows) : (y += 1) {
const pin = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse continue;
const cells = pin.cells(.all);
const row_top = pad + @as(f64, @floatFromInt(y)) * self.cell_h;
const span: ?[2]usize = if (bounds) |b| span: {
const p = screen.pages.pointFromPin(.screen, pin) orelse break :span null;
break :span b.span(p.screen.y, cells.len);
} else null;
// Pass 1: backgrounds. Drawn as one rect per run so that a wide
// block of color doesn't turn into hundreds of tiny fills.
var x: usize = 0;
while (x < cells.len) {
const start_bg = appearance(pin, &cells[x], term, default_fg, default_bg).bg;
const start_bg = styled(pin, &cells[x], term, default_fg, default_bg, inSpan(span, x)).bg;
var end = x + 1;
while (end < cells.len) : (end += 1) {
const next = appearance(pin, &cells[end], term, default_fg, default_bg).bg;
const next = styled(pin, &cells[end], term, default_fg, default_bg, inSpan(span, end)).bg;
if (!std.meta.eql(start_bg, next)) break;
}
@@ -421,7 +1018,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
continue;
}
const look = appearance(pin, &cells[x], term, default_fg, default_bg);
const look = styled(pin, &cells[x], term, default_fg, default_bg, inSpan(span, x));
self.run_buf.clearRetainingCapacity();
const run_start = x;
@@ -429,7 +1026,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
const cell = &cells[x];
if (cell.wide == .spacer_tail) continue;
const cell_look = appearance(pin, cell, term, default_fg, default_bg);
const cell_look = styled(pin, cell, term, default_fg, default_bg, inSpan(span, x));
if (x != run_start and !look.sameRun(cell_look)) break;
try self.appendCell(pin, cell);
+9 -53
View File
@@ -1424,10 +1424,17 @@ fn onShortcut(
// Only a terminal needs us to encode a paste for it. A web
// pane has its own clipboard handling, so the key is left
// alone rather than swallowed here.
if (self.focusedTerminal() == null) return 0;
self.paste();
const terminal = self.focusedTerminal() orelse return 0;
terminal.pasteFrom(.standard);
return 1;
},
gdk.KEY_C, gdk.KEY_c => {
// With nothing selected the key is declined rather than
// swallowed, so a web pane's own copy still works and a
// terminal still receives it.
const terminal = self.focusedTerminal() orelse return 0;
return if (terminal.copySelection(.standard)) 1 else 0;
},
else => {},
}
}
@@ -1495,54 +1502,3 @@ fn onShortcut(
return 0;
}
// -------------------------------------------------------------------------
// Paste
//
// GTK4's clipboard API is asynchronous, so the read completes on a later
// main loop turn. We resolve the destination tab at completion time rather
// than capturing it, so closing a tab mid-paste can't leave a dangling
// pointer.
fn paste(self: *Window) void {
const clipboard = self.window.as(gtk.Widget).getClipboard();
clipboard.readTextAsync(null, &onPasteReady, self);
}
fn onPasteReady(
source: ?*gobject.Object,
result: *gio.AsyncResult,
data: ?*anyopaque,
) callconv(.c) void {
const self: *Window = @ptrCast(@alignCast(data.?));
const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?));
var err: ?*glib.Error = null;
const text = clipboard.readTextFinish(result, &err) orelse {
if (err) |e| {
std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"});
e.free();
}
return;
};
defer glib.free(text);
const terminal = self.focusedTerminal() orelse return;
const session = terminal.session;
// Coerce to a plain slice: encodePaste dispatches on the exact type.
const span: []const u8 = std.mem.span(text);
// Refuse pastes containing control characters that would execute on
// arrival (a newline in unbracketed mode runs the command immediately).
const opts: vt.input.PasteOptions = .fromTerminal(&session.term);
if (!vt.input.isSafePaste(span)) {
std.log.warn("refusing unsafe paste", .{});
return;
}
const parts = vt.input.encodePaste(span, opts) catch |e| {
std.log.warn("paste encode failed: {s}", .{@errorName(e)});
return;
};
for (parts) |part| session.write(part);
}
+20
View File
@@ -50,12 +50,22 @@ const Palette = struct {
/// Cursor block color.
cursor: Rgb,
/// Background and foreground of selected text. Both are fixed rather than
/// derived from the cell underneath, because a selection has to read as
/// one continuous block across text the program has coloured every which
/// way — a translucent wash over the existing colors leaves the highlight
/// looking like a rendering artefact wherever the text is already bright.
selection_bg: Rgb,
selection_fg: Rgb,
};
const dark: Palette = .{
.bg = .{ .r = 0x0e, .g = 0x16, .b = 0x24 },
.fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 },
.cursor = .{ .r = 0x61, .g = 0x91, .b = 0xf3 },
.selection_bg = .{ .r = 0x2f, .g = 0x52, .b = 0x96 },
.selection_fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 },
};
/// Not a straight inversion. The cursor darkens rather than lightens, because
@@ -67,6 +77,8 @@ const light: Palette = .{
.bg = .{ .r = 0xff, .g = 0xff, .b = 0xff },
.fg = .{ .r = 0x16, .g = 0x1f, .b = 0x2e },
.cursor = .{ .r = 0x2c, .g = 0x6b, .b = 0xed },
.selection_bg = .{ .r = 0xa9, .g = 0xc4, .b = 0xf7 },
.selection_fg = .{ .r = 0x16, .g = 0x1f, .b = 0x2e },
};
var scheme: Scheme = .dark;
@@ -100,6 +112,14 @@ pub fn cursor() Rgb {
return palette().cursor;
}
pub fn selectionBg() Rgb {
return palette().selection_bg;
}
pub fn selectionFg() Rgb {
return palette().selection_fg;
}
// -------------------------------------------------------------------------
// The ANSI palette
//