Fix mouse interactions in terminal.
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user