vtabs: terminal with vertical tabs on libghostty-vt + GTK4
Uses libghostty-vt (the API Ghostty documents for external embedders) for the terminal core. Ghostty's other C API, ghostty.h, exposes a full terminal surface but only supports macOS and iOS platform tags, so it cannot be embedded on Linux. We supply the layers libghostty-vt deliberately leaves out: PTY and process management, a Cairo/Pango cell renderer, and a GTK4/libadwaita UI with a Zen-style vertical tab sidebar. Nix pins the whole toolchain (Zig 0.16 via zig-overlay, GTK 4.22, libadwaita) so no host setup is needed.
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
//! 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 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 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;
|
||||
|
||||
/// Corner rounding of the terminal pane.
|
||||
const corner_radius: f64 = 10;
|
||||
|
||||
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,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
cbs: struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
},
|
||||
) !*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,
|
||||
.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, .{
|
||||
.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));
|
||||
|
||||
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 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, width: c_int, height: c_int) !void {
|
||||
const term = &self.session.term;
|
||||
const screen = term.screens.active;
|
||||
|
||||
// Background. Clipped to a rounded rectangle so the terminal reads as an
|
||||
// inset pane next to the sidebar, the way Zen insets web content.
|
||||
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
|
||||
.from(c)
|
||||
else
|
||||
theme.bg;
|
||||
{
|
||||
roundedRect(
|
||||
cr,
|
||||
0,
|
||||
0,
|
||||
@floatFromInt(width),
|
||||
@floatFromInt(height),
|
||||
corner_radius,
|
||||
);
|
||||
cr.clip();
|
||||
|
||||
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();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace a rounded rectangle as the current path.
|
||||
fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void {
|
||||
const radius = @min(r, @min(w, h) / 2);
|
||||
const pi = std.math.pi;
|
||||
|
||||
cr.newSubPath();
|
||||
cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0);
|
||||
cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0);
|
||||
cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi);
|
||||
cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0);
|
||||
cr.closePath();
|
||||
}
|
||||
|
||||
/// 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user