//! The terminal widget: draws a libghostty-vt screen with Cairo/Pango and //! feeds user input back to the session. //! //! Rendering is deliberately simple. Ghostty itself uses a GPU renderer with //! a glyph atlas; here we walk the visible rows every frame and hand runs of //! same-styled text to Pango. That is far slower in principle, but a terminal //! grid is small and this keeps the proof of concept readable. 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"); const pangocairo = @import("pangocairo"); const vt = @import("ghostty-vt"); const keymap = @import("key.zig"); const theme = @import("theme.zig"); const Pane = @import("Pane.zig"); const Session = @import("Session.zig"); const Terminal = @This(); /// Pango measures in 1/1024ths of a device unit. const pango_scale: f64 = @floatFromInt(pango.SCALE); /// The font we render with. Any monospace family the system provides. 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, area: *gtk.DrawingArea, font: *pango.FontDescription, /// Cell geometry derived from the font metrics. 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, /// Called when the session's title changes, so the owner can retitle the tab. on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, /// Called when this terminal takes keyboard focus. on_focus: *const fn (ctx: ?*anyopaque) void, /// Called when the child reports a change of state, so the owner can show /// it in the tab strip. on_status: *const fn (ctx: ?*anyopaque, status: Status) void, /// Called when the user types into this terminal. Focus isn't enough for /// this: the owner uses it as the sign that whatever the session had to say /// has been dealt with, and that is answering, not looking. on_input: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, /// What a layout can specify for a terminal pane. 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, cbs: Pane.Callbacks, ) !*Terminal { const self = try alloc.create(Terminal); errdefer alloc.destroy(self); const area = gtk.DrawingArea.new(); const font = pango.FontDescription.fromString(font_spec); self.* = .{ .alloc = alloc, .session = undefined, .area = area, .font = font, .on_title = cbs.on_title, .on_exit = cbs.on_exit, .on_focus = cbs.on_focus, .on_status = cbs.on_status, .on_input = cbs.on_input, .ctx = cbs.ctx, }; self.measureFont(); // The grid size follows the widget size, but we need a starting point // for the session before the widget has ever been allocated. self.session = try .create(alloc, 80, 24, opts, .{ .on_damage = &onDamage, .on_title = &onSessionTitle, .on_exit = &onSessionExit, .on_status = &onSessionStatus, .ctx = self, }); 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 // key events would go to the sidebar instead. 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, .{}); const keys = gtk.EventControllerKey.new(); _ = gtk.EventControllerKey.signals.key_pressed.connect( keys, *Terminal, &onKeyPressed, self, .{}, ); w.addController(keys.as(gtk.EventController)); const scroll = gtk.EventControllerScroll.new(.{ .vertical = true }); _ = gtk.EventControllerScroll.signals.scroll.connect( scroll, *Terminal, &onScroll, self, .{}, ); w.addController(scroll.as(gtk.EventController)); // 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, &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( area, *Terminal, &onNotifyHasFocus, self, .{ .detail = "has-focus" }, ); return self; } 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(); self.alloc.destroy(self); } pub fn widget(self: *Terminal) *gtk.Widget { return self.area.as(gtk.Widget); } pub fn grabFocus(self: *Terminal) void { _ = self.widget().grabFocus(); } /// Derive cell geometry from the font. A monospace font's "approximate /// character width" is its advance, which is exactly our cell width. fn measureFont(self: *Terminal) void { const ctx = self.area.as(gtk.Widget).createPangoContext(); defer ctx.unref(); const metrics = ctx.getMetrics(self.font, null); defer metrics.unref(); const ascent = @as(f64, @floatFromInt(metrics.getAscent())) / pango_scale; const descent = @as(f64, @floatFromInt(metrics.getDescent())) / pango_scale; const char_w = @as(f64, @floatFromInt(metrics.getApproximateCharWidth())) / pango_scale; self.cell_w = @max(1, @ceil(char_w)); self.cell_h = @max(1, @ceil(ascent + descent)); self.ascent = ascent; } fn onDamage(ctx: ?*anyopaque) void { const self: *Terminal = @ptrCast(@alignCast(ctx.?)); self.area.as(gtk.Widget).queueDraw(); } fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void { const self: *Terminal = @ptrCast(@alignCast(ctx.?)); self.on_title(self.ctx, title); } fn onSessionStatus(ctx: ?*anyopaque, status: Status) void { const self: *Terminal = @ptrCast(@alignCast(ctx.?)); self.on_status(self.ctx, status); } fn onNotifyHasFocus( _: *gtk.DrawingArea, _: *gobject.ParamSpec, self: *Terminal, ) callconv(.c) void { if (self.widget().hasFocus() != 0) self.on_focus(self.ctx); } fn onSessionExit(ctx: ?*anyopaque) void { const self: *Terminal = @ptrCast(@alignCast(ctx.?)); self.on_exit(self.ctx); } /// The widget was resized: recompute the grid and tell the child process. fn onResize(_: *gtk.DrawingArea, width: c_int, height: c_int, self: *Terminal) callconv(.c) void { const usable_w = @max(0.0, @as(f64, @floatFromInt(width)) - pad * 2); const usable_h = @max(0.0, @as(f64, @floatFromInt(height)) - pad * 2); const cols: u16 = @intFromFloat(@max(1, @floor(usable_w / self.cell_w))); const rows: u16 = @intFromFloat(@max(1, @floor(usable_h / self.cell_h))); self.session.resize( cols, rows, @intFromFloat(self.cell_w), @intFromFloat(self.cell_h), ) catch |err| { std.log.warn("resize failed: {s}", .{@errorName(err)}); }; } // ------------------------------------------------------------------------- // 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. /// 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, 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( _: *gtk.EventControllerScroll, _: f64, dy: f64, self: *Terminal, ) callconv(.c) c_int { if (dy == 0) return 0; // 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, _: c_uint, state: gdk.ModifierType, self: *Terminal, ) callconv(.c) c_int { const mods = keymap.translateMods(state); var event: vt.input.KeyEvent = .{ .action = .press, .key = keymap.keyFromKeyval(keyval) orelse .unidentified, .mods = mods, }; // GDK has already applied the keyboard layout and shift level, so the // unicode value of the keyval is the text this key produces. var utf8_buf: [8]u8 = undefined; const codepoint = gdk.keyvalToUnicode(keyval); if (codepoint > 0) { if (std.unicode.utf8Encode(@intCast(codepoint), &utf8_buf)) |n| { event.utf8 = utf8_buf[0..n]; // Shift is consumed producing the shifted character; ctrl/alt // are not, and the encoder needs to know that to build e.g. // ctrl sequences correctly. event.consumed_mods = .{ .shift = mods.shift }; } else |_| {} const lower = gdk.keyvalToLower(keyval); const unshifted = gdk.keyvalToUnicode(lower); if (unshifted > 0) event.unshifted_codepoint = @intCast(unshifted); } var out: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&out); const opts: vt.input.KeyEncodeOptions = .fromTerminal(&self.session.term); vt.input.encodeKey(&writer, event, opts) catch |err| { std.log.warn("key encode failed: {s}", .{@errorName(err)}); return 0; }; const encoded = writer.buffered(); // Keys with no terminal representation (bare modifiers, unmapped keys) // encode to nothing. Let GTK keep processing them. if (encoded.len == 0) return 0; // 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(); return 1; } // ------------------------------------------------------------------------- // Rendering /// A cell's appearance after resolving palette indices and SGR attributes. const Appearance = struct { fg: theme.Rgb, bg: ?theme.Rgb, bold: bool, italic: bool, underline: bool, strikethrough: bool, fn sameRun(a: Appearance, b: Appearance) bool { return std.meta.eql(a.fg, b.fg) and a.bold == b.bold and a.italic == b.italic and a.underline == b.underline and a.strikethrough == b.strikethrough; } }; /// 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, width: c_int, height: c_int, data: ?*anyopaque, ) callconv(.c) void { const self: *Terminal = @ptrCast(@alignCast(data.?)); self.render(cr, width, height) catch |err| { std.log.warn("render failed: {s}", .{@errorName(err)}); }; } fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { const term = &self.session.term; const screen = term.screens.active; // Background. The pane frame around us clips to its own rounded corners, // so this just fills. const default_bg: theme.Rgb = if (term.colors.background.get()) |c| .fromVt(c) else theme.bg(); { const r, const g, const b = default_bg.cairoRgb(); cr.setSourceRgb(r, g, b); cr.paint(); } const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c| .fromVt(c) else theme.fg(); const layout = pangocairo.createLayout(cr); 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 = 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 = styled(pin, &cells[end], term, default_fg, default_bg, inSpan(span, end)).bg; if (!std.meta.eql(start_bg, next)) break; } if (start_bg) |color| { const r, const g, const b = color.cairoRgb(); cr.setSourceRgb(r, g, b); cr.rectangle( pad + @as(f64, @floatFromInt(x)) * self.cell_w, row_top, @as(f64, @floatFromInt(end - x)) * self.cell_w, self.cell_h, ); cr.fill(); } x = end; } // Pass 2: text runs. x = 0; while (x < cells.len) { if (cells[x].wide == .spacer_tail) { x += 1; continue; } const look = styled(pin, &cells[x], term, default_fg, default_bg, inSpan(span, x)); self.run_buf.clearRetainingCapacity(); const run_start = x; while (x < cells.len) : (x += 1) { const cell = &cells[x]; if (cell.wide == .spacer_tail) continue; 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); } if (self.run_buf.items.len > 0) { try self.drawRun( cr, layout, look, pad + @as(f64, @floatFromInt(run_start)) * self.cell_w, row_top, ); } } } self.drawCursor(cr, layout, term, default_bg); } /// Append a cell's text to the current run. fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void { switch (cell.content_tag) { .codepoint, .codepoint_grapheme => { const cp = cell.content.codepoint.data; // An empty cell still occupies a column, so emit a space to // keep the run's characters aligned to the grid. try self.appendCodepoint(if (cp == 0) ' ' else cp); if (cell.content_tag == .codepoint_grapheme) { if (pin.grapheme(cell)) |extra| { for (extra) |cp2| try self.appendCodepoint(cp2); } } }, // Background-only cells have no text. .bg_color_palette, .bg_color_rgb => try self.appendCodepoint(' '), } } fn appendCodepoint(self: *Terminal, cp: u21) !void { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch return; try self.run_buf.appendSlice(self.alloc, buf[0..n]); } fn drawRun( self: *Terminal, cr: *cairo.Context, layout: *pango.Layout, look: Appearance, x: f64, y: f64, ) !void { self.font.setWeight(if (look.bold) .bold else .normal); self.font.setStyle(if (look.italic) .italic else .normal); layout.setFontDescription(self.font); // Pango wants a NUL-terminated pointer even though we pass the length. try self.run_buf.append(self.alloc, 0); const text = self.run_buf.items[0 .. self.run_buf.items.len - 1 :0]; layout.setText(text.ptr, @intCast(text.len)); const r, const g, const b = look.fg.cairoRgb(); cr.setSourceRgb(r, g, b); cr.moveTo(x, y); pangocairo.showLayout(cr, layout); // Pango has no notion of our grid, so decorations are drawn by hand // across the exact width of the run. const run_w = runWidth(layout); if (look.underline) { cr.rectangle(x, y + self.ascent + 2, run_w, 1); cr.fill(); } if (look.strikethrough) { cr.rectangle(x, y + self.ascent * 0.6, run_w, 1); cr.fill(); } } fn runWidth(layout: *pango.Layout) f64 { var w: c_int = 0; var h: c_int = 0; layout.getPixelSize(&w, &h); return @floatFromInt(w); } fn drawCursor( self: *Terminal, cr: *cairo.Context, layout: *pango.Layout, term: *vt.Terminal, default_bg: theme.Rgb, ) void { // Only show the cursor when we're looking at the live screen; while // scrolled back into history there is nothing meaningful to point at. if (term.screens.active.pages.viewport != .active) return; if (!term.modes.get(.cursor_visible)) return; const cursor = term.screens.active.cursor; if (cursor.x >= term.cols or cursor.y >= term.rows) return; const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w; const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h; const color: theme.Rgb = if (term.colors.cursor.get()) |c| .fromVt(c) else theme.cursor(); const r, const g, const b = color.cairoRgb(); cr.setSourceRgb(r, g, b); switch (cursor.cursor_style) { .block => { cr.rectangle(x, y, self.cell_w, self.cell_h); cr.fill(); // Redraw the covered character in the background color so it // stays legible through the block. const pin = term.screens.active.pages.pin(.{ .viewport = .{ .x = cursor.x, .y = cursor.y }, }) orelse return; const cell = pin.rowAndCell().cell; if (cell.content_tag != .codepoint and cell.content_tag != .codepoint_grapheme) return; const cp = cell.content.codepoint.data; if (cp == 0 or cp == ' ') return; var buf: [5]u8 = @splat(0); const n = std.unicode.utf8Encode(cp, buf[0..4]) catch return; layout.setText(buf[0..n :0].ptr, @intCast(n)); const tr, const tg, const tb = default_bg.cairoRgb(); cr.setSourceRgb(tr, tg, tb); cr.moveTo(x, y); pangocairo.showLayout(cr, layout); }, .bar => { cr.rectangle(x, y, 2, self.cell_h); cr.fill(); }, .underline => { cr.rectangle(x, y + self.cell_h - 2, self.cell_w, 2); cr.fill(); }, .block_hollow => { cr.rectangle(x + 0.5, y + 0.5, self.cell_w - 1, self.cell_h - 1); cr.setLineWidth(1); cr.stroke(); }, } } /// Resolve a cell's style into concrete colors and attributes. fn appearance( pin: vt.Pin, cell: *const vt.Cell, term: *vt.Terminal, default_fg: theme.Rgb, default_bg: theme.Rgb, ) Appearance { const palette = &term.colors.palette.current; // Cells that carry only a background color have no style entry. switch (cell.content_tag) { .bg_color_palette => return .{ .fg = default_fg, .bg = .fromVt(palette[cell.content.color_palette.data]), .bold = false, .italic = false, .underline = false, .strikethrough = false, }, .bg_color_rgb => { const c = cell.content.color_rgb; return .{ .fg = default_fg, .bg = .{ .r = c.r, .g = c.g, .b = c.b }, .bold = false, .italic = false, .underline = false, .strikethrough = false, }; }, else => {}, } const style = pin.style(cell); var fg: theme.Rgb = switch (style.fg_color) { .none => default_fg, .palette => |i| brightIfBold(palette, i, style.flags.bold), .rgb => |c| .fromVt(c), }; var bg: ?theme.Rgb = switch (style.bg_color) { .none => null, .palette => |i| .fromVt(palette[i]), .rgb => |c| .fromVt(c), }; if (style.flags.inverse) { const new_fg = bg orelse default_bg; const new_bg = fg; fg = new_fg; bg = new_bg; } if (style.flags.invisible) fg = bg orelse default_bg; return .{ .fg = fg, .bg = bg, .bold = style.flags.bold, .italic = style.flags.italic, .underline = style.flags.underline != .none, .strikethrough = style.flags.strikethrough, }; } /// Bold text using one of the low 8 palette colors conventionally renders /// with the matching bright color. fn brightIfBold(palette: *const [256]vt.color.RGB, index: u8, bold: bool) theme.Rgb { const effective = if (bold and index < 8) index + 8 else index; return .fromVt(palette[effective]); }