diff --git a/README.md b/README.md index d704e7f..4906762 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Terminal.zig b/src/Terminal.zig index bbd383c..3a6cd0b 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -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); diff --git a/src/Window.zig b/src/Window.zig index 680fd91..a41159e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); -} diff --git a/src/theme.zig b/src/theme.zig index 63e2778..6ed7dc7 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -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 //