Files
playpen/src/Terminal.zig
T

1252 lines
42 KiB
Zig

//! 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]);
}