//! 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 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; 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, /// 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, ctx: ?*anyopaque = null, /// What a layout can specify for a terminal pane. pub const Options = Session.Options; 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, .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, .ctx = self, }); errdefer self.session.destroy(); const w = area.as(gtk.Widget); 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); 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)); // Clicking the terminal should focus it. const click = gtk.GestureClick.new(); _ = gtk.GestureClick.signals.pressed.connect( click, *Terminal, &onClick, self, .{}, ); w.addController(click.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 { 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 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)}); }; } // ------------------------------------------------------------------------- // Input fn onClick( _: *gtk.GestureClick, _: c_int, _: f64, _: f64, self: *Terminal, ) callconv(.c) void { self.grabFocus(); } fn onScroll( _: *gtk.EventControllerScroll, _: f64, 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; self.session.term.screens.active.pages.scroll(.{ .delta_row = delta }); self.area.as(gtk.Widget).queueDraw(); return 1; } 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. self.session.term.screens.active.pages.scroll(.active); self.session.write(encoded); 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; } }; 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| .from(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| .from(c) else theme.fg; const layout = pangocairo.createLayout(cr); defer layout.unref(); layout.setFontDescription(self.font); 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; // 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; var end = x + 1; while (end < cells.len) : (end += 1) { const next = appearance(pin, &cells[end], term, default_fg, default_bg).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 = appearance(pin, &cells[x], term, default_fg, default_bg); 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 = appearance(pin, cell, term, default_fg, default_bg); 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| .from(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 = .from(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| .from(c), }; var bg: ?theme.Rgb = switch (style.bg_color) { .none => null, .palette => |i| .from(palette[i]), .rgb => |c| .from(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 .from(palette[effective]); }