src/main.zig
Ref: Size: 116.3 KiB
const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const wayland_client = @import("wayland-client");
const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
const vk = @import("vulkan");
const c = @cImport({
@cInclude("xkbcommon/xkbcommon-keysyms.h");
});
const GridSize = struct {
cols: u16,
rows: u16,
};
const ScaledGeometry = struct {
buffer_scale: i32,
px_size: u32,
cell_w_px: u32, // buffer pixels
cell_h_px: u32, // buffer pixels
baseline_px: u32,
};
fn rebuildFaceForScale(
face: *font.Face,
atlas: *font.Atlas,
font_path: [:0]const u8,
font_index: c_int,
base_px_size: u32,
buffer_scale: i32,
) !ScaledGeometry {
const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
const new_px = base_px_size * scale;
try face.reinit(font_path, font_index, new_px);
atlas.reset();
return .{
.buffer_scale = @intCast(scale),
.px_size = new_px,
.cell_w_px = face.cellWidth(),
.cell_h_px = face.cellHeight(),
.baseline_px = face.baseline(),
};
}
fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void {
const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return));
_ = p.write(data) catch |err| {
std.log.warn("pty write callback failed: {}", .{err});
return;
};
}
fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) void {
const window: *wayland_client.Window = @ptrCast(@alignCast(ctx orelse return));
window.setTitle(title);
}
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
if (args.len >= 2 and std.mem.eql(u8, args[1], "--headless")) {
return runHeadless(alloc);
}
if (args.len >= 2 and std.mem.eql(u8, args[1], "--wayland-smoke-test")) {
return runWaylandSmokeTest(alloc);
}
if (args.len >= 2 and std.mem.eql(u8, args[1], "--vulkan-smoke-test")) {
return runVulkanSmokeTest(alloc);
}
if (args.len >= 2 and std.mem.eql(u8, args[1], "--render-smoke-test")) {
return runRenderSmokeTest(alloc);
}
if (args.len >= 2 and std.mem.eql(u8, args[1], "--draw-smoke-test")) {
return runDrawSmokeTest(alloc);
}
if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
return runTextCoverageCompare(alloc);
}
return runTerminal(alloc);
}
fn runTerminal(alloc: std.mem.Allocator) !void {
// === font first, to know cell size ===
var font_lookup = try font.lookupConfiguredFont(alloc);
defer font_lookup.deinit(alloc);
const font_size: u32 = config.font_size_px;
var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size);
defer face.deinit();
var geom: ScaledGeometry = .{
.buffer_scale = 1,
.px_size = font_size,
.cell_w_px = face.cellWidth(),
.cell_h_px = face.cellHeight(),
.baseline_px = face.baseline(),
};
// Mutable aliases so the scale-change path inside the main loop can
// refresh them after a rebuildFaceForScale call without renaming every
// downstream reference.
var cell_w = geom.cell_w_px;
var cell_h = geom.cell_h_px;
var baseline = geom.baseline_px;
// === grid size ===
const initial_grid: GridSize = .{ .cols = 80, .rows = 24 };
var cols: u16 = initial_grid.cols;
var rows: u16 = initial_grid.rows;
const initial_w: u32 = @as(u32, cols) * cell_w;
const initial_h: u32 = @as(u32, rows) * cell_h;
// === wayland ===
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
const window = try conn.createWindow(alloc, "waystty");
defer window.deinit();
// Set window to desired terminal dimensions
window.width = initial_w;
window.height = initial_h;
_ = conn.display.roundtrip();
// === keyboard ===
var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?);
defer keyboard.deinit();
// === pointer ===
var pointer = try wayland_client.Pointer.init(alloc, conn.globals.seat.?);
defer pointer.deinit();
// === selection UI state ===
var selection: SelectionState = .{};
// === clipboard ===
const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager|
try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?)
else
null;
defer if (clipboard) |cb| cb.deinit();
// === renderer ===
var ctx = try renderer.Context.init(
alloc,
@ptrCast(conn.display),
@ptrCast(window.surface),
window.width,
window.height,
);
defer ctx.deinit();
// === glyph atlas ===
var atlas = try font.Atlas.init(alloc, 1024, 1024);
defer atlas.deinit();
// Precompute printable ASCII glyphs (32-126) into atlas
for (32..127) |cp| {
_ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
error.AtlasFull => break,
else => return err,
};
}
// Upload warm atlas (full upload — descriptor set needs valid data)
try ctx.uploadAtlas(atlas.pixels);
atlas.last_uploaded_y = atlas.cursor_y;
atlas.needs_full_upload = false;
atlas.dirty = false;
// === terminal ===
var term = try vt.Terminal.init(alloc, .{
.cols = cols,
.rows = rows,
.max_scrollback = 1000,
});
defer term.deinit();
term.setTitleChangedCallback(window, &updateWindowTitle);
term.setReportedSize(.{
.rows = rows,
.columns = cols,
.cell_width = cell_w,
.cell_height = cell_h,
});
// === pty ===
const shell: [:0]const u8 = blk: {
if (std.posix.getenv("WAYSTTY_BENCH") != null) {
break :blk try alloc.dupeZ(u8, "/bin/sh");
}
const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
break :blk try alloc.dupeZ(u8, shell_env);
};
defer alloc.free(shell);
const bench_script: ?[:0]const u8 = if (std.posix.getenv("WAYSTTY_BENCH") != null)
"echo warmup; sleep 0.2; seq 1 50000; find /usr/lib -name '*.so' 2>/dev/null | head -500; yes 'hello world' | head -2000; exit 0"
else
null;
var p = try pty.Pty.spawn(.{
.cols = cols,
.rows = rows,
.shell = shell,
.shell_args = if (bench_script) |script| &.{ "-c", script } else null,
});
defer p.deinit();
term.setWritePtyCallback(&p, &writePtyFromTerminal);
// === render cache ===
var render_cache = RenderCache.empty;
defer render_cache.deinit(alloc);
try render_cache.resizeRows(alloc, rows);
// === frame timing ===
var frame_ring = FrameTimingRing{};
installSigusr1Handler();
// === main loop ===
const wl_fd = conn.display.getFd();
var pollfds = [_]std.posix.pollfd{
.{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
};
var read_buf: [8192]u8 = undefined;
var key_buf: [32]u8 = undefined;
var last_window_w = window.width;
var last_window_h = window.height;
var last_scale: i32 = geom.buffer_scale;
var render_pending = true;
while (!window.should_close and p.isChildAlive()) {
// Flush any pending wayland requests
_ = conn.display.flush();
const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
_ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {};
// Wayland events: prepare_read / read_events / dispatch_pending
if (pollfds[0].revents & std.posix.POLL.IN != 0) {
if (conn.display.prepareRead()) {
_ = conn.display.readEvents();
}
}
_ = conn.display.dispatchPending();
// PTY output
if (pollfds[1].revents & std.posix.POLL.IN != 0) {
while (true) {
const n = p.read(&read_buf) catch |err| switch (err) {
error.WouldBlock => break,
error.InputOutput => break, // child likely exited
else => return err,
};
if (n == 0) break;
term.write(read_buf[0..n]);
render_pending = true;
}
}
// Pointer events → update selection state
const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
const prev_selection = activeSelectionSpan(selection);
for (pointer.event_queue.items) |ev| {
handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows);
}
const selection_changed = !std.meta.eql(activeSelectionSpan(selection), prev_selection);
if (pointer.event_queue.items.len > 0) {
pointer.event_queue.clearRetainingCapacity();
render_pending = true;
}
// Keyboard events → write to pty
keyboard.tickRepeat();
for (keyboard.event_queue.items) |ev| {
if (ev.action == .release) continue;
if (isClipboardPasteEvent(ev)) {
if (clipboard) |cb| {
if (try cb.receiveSelectionText(alloc)) |text| {
defer alloc.free(text);
const encoded = term.encodePaste(text);
for (encoded) |chunk| {
if (chunk.len == 0) continue;
_ = try p.write(chunk);
}
}
}
continue;
}
if (isClipboardCopyEvent(ev)) {
_ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection), ev.serial);
continue;
}
if (ev.utf8_len > 0) {
_ = try p.write(ev.utf8[0..ev.utf8_len]);
} else if (try encodeKeyboardEvent(term, ev, &key_buf)) |encoded| {
_ = try p.write(encoded);
}
}
keyboard.event_queue.clearRetainingCapacity();
const current_scale = window.bufferScale();
if (current_scale != last_scale) {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
geom = try rebuildFaceForScale(
&face,
&atlas,
font_lookup.path,
font_lookup.index,
font_size,
current_scale,
);
cell_w = geom.cell_w_px;
cell_h = geom.cell_h_px;
baseline = geom.baseline_px;
// Wipe all cached row instances + mark the terminal fully dirty so that
// every glyph gets re-inserted into the freshly reset atlas next frame.
render_cache.invalidateAfterResize();
term.render_state.dirty = .full;
window.surface.setBufferScale(geom.buffer_scale);
const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
try ctx.recreateSwapchain(buf_w, buf_h);
last_scale = current_scale;
render_pending = true;
}
if (window.width != last_window_w or window.height != last_window_h) {
// Grid is sized in surface coordinates. cell_w/cell_h are in buffer
// pixels, so divide by buffer_scale to get surface-pixel cell dims.
const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
const surf_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
if (new_grid.cols != cols or new_grid.rows != rows) {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
try ctx.recreateSwapchain(buf_w, buf_h);
try term.resize(new_grid.cols, new_grid.rows);
try p.resize(new_grid.cols, new_grid.rows);
cols = new_grid.cols;
rows = new_grid.rows;
term.setReportedSize(.{
.rows = rows,
.columns = cols,
.cell_width = cell_w,
.cell_height = cell_h,
});
selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
} else {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
try ctx.recreateSwapchain(buf_w, buf_h);
}
last_window_w = window.width;
last_window_h = window.height;
render_pending = true;
}
if (!shouldRenderFrame(render_pending, false, false)) continue;
var frame_timing: FrameTiming = .{};
// === render ===
const previous_cursor = term.render_state.cursor;
var section_timer = std.time.Timer.start() catch unreachable;
try term.snapshot();
frame_timing.snapshot_us = usFromTimer(§ion_timer);
section_timer = std.time.Timer.start() catch unreachable;
const default_bg = term.backgroundColor();
const bg_uv = atlas.cursorUV();
const term_rows = term.render_state.row_data.items(.cells);
const dirty_rows = term.render_state.row_data.items(.dirty);
try render_cache.resizeRows(alloc, term_rows.len);
const refresh_plan = planRowRefresh(
if (term.render_state.dirty == .full or selection_changed) .full else .partial,
dirty_rows,
.{
.cursor = .{
.old_row = if (previous_cursor.viewport) |cursor| @intCast(cursor.y) else null,
.new_row = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.y) else null,
.old_col = if (previous_cursor.viewport) |cursor| @intCast(cursor.x) else null,
.new_col = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.x) else null,
.old_visible = previous_cursor.visible,
.new_visible = term.render_state.cursor.visible,
},
},
);
var rows_rebuilt: usize = 0;
var row_idx: usize = 0;
const current_selection = activeSelectionSpan(selection);
while (row_idx < term_rows.len) : (row_idx += 1) {
if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue;
const previous_gpu_offset = render_cache.rows[row_idx].gpu_offset_instances;
const previous_gpu_len = render_cache.rows[row_idx].gpu_len_instances;
const rebuilt = try rebuildRowInstances(
alloc,
&render_cache.rows[row_idx],
@intCast(row_idx),
term_rows[row_idx],
term,
&face,
&atlas,
cell_w,
cell_h,
baseline,
default_bg,
bg_uv,
current_selection,
);
if (rebuilt.len_changed) {
render_cache.layout_dirty = true;
} else {
render_cache.rows[row_idx].gpu_offset_instances = previous_gpu_offset;
render_cache.rows[row_idx].gpu_len_instances = previous_gpu_len;
}
rows_rebuilt += 1;
}
var cursor_rebuilt = false;
if (refresh_plan.cursor_rebuild) {
var cursor_instances_buf: [1]renderer.Instance = undefined;
var cursor_instances: []const renderer.Instance = &.{};
if (term.render_state.cursor.viewport) |cursor| {
const cursor_uv = atlas.cursorUV();
cursor_instances_buf[0] = .{
.cell_pos = .{
@floatFromInt(cursor.x),
@floatFromInt(cursor.y),
},
.glyph_size = .{
@floatFromInt(cell_w),
@floatFromInt(cell_h),
},
.glyph_bearing = .{ 0, 0 },
.uv_rect = .{
cursor_uv.u0,
cursor_uv.v0,
cursor_uv.u1,
cursor_uv.v1,
},
.fg = .{ 1.0, 1.0, 1.0, 0.5 },
.bg = .{ 0, 0, 0, 0 },
};
cursor_instances = cursor_instances_buf[0..1];
}
const previous_total_instance_count = render_cache.total_instance_count;
const previous_layout_dirty = render_cache.layout_dirty;
const rebuilt = try render_cache.rebuildCursorInstances(alloc, cursor_instances);
if (!rebuilt.len_changed) {
render_cache.layout_dirty = previous_layout_dirty;
render_cache.total_instance_count = previous_total_instance_count;
}
cursor_rebuilt = true;
}
frame_timing.row_rebuild_us = usFromTimer(§ion_timer);
section_timer = std.time.Timer.start() catch unreachable;
// Re-upload atlas if new glyphs were added (incremental)
if (atlas.dirty) {
const y_start = atlas.last_uploaded_y;
const y_end = atlas.cursor_y + atlas.row_height;
if (y_start < y_end) {
try ctx.uploadAtlasRegion(
atlas.pixels,
y_start,
y_end,
atlas.needs_full_upload,
);
atlas.last_uploaded_y = atlas.cursor_y;
atlas.needs_full_upload = false;
render_cache.layout_dirty = true;
}
atlas.dirty = false;
}
frame_timing.atlas_upload_us = usFromTimer(§ion_timer);
section_timer = std.time.Timer.start() catch unreachable;
const upload_plan = applyRenderPlan(.{
.layout_dirty = render_cache.layout_dirty,
.rows_rebuilt = rows_rebuilt,
.cursor_rebuilt = cursor_rebuilt,
});
if (upload_plan.full_upload) {
const pack_result = try repackRowCaches(
alloc,
&render_cache.packed_instances,
render_cache.rows,
render_cache.cursor_instances.items,
);
render_cache.total_instance_count = pack_result.total_instances;
render_cache.layout_dirty = false;
if (render_cache.packed_instances.items.len > 0) {
try ctx.uploadInstances(render_cache.packed_instances.items);
}
} else if (upload_plan.partial_upload) {
var fallback_to_full_upload = false;
var upload_row_idx: usize = 0;
while (upload_row_idx < term_rows.len) : (upload_row_idx += 1) {
if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(upload_row_idx)) continue;
const row_cache = &render_cache.rows[upload_row_idx];
if (try ctx.uploadInstanceRange(
row_cache.gpu_offset_instances,
row_cache.instances.items,
)) {
fallback_to_full_upload = true;
break;
}
}
if (!fallback_to_full_upload and cursor_rebuilt) {
if (try ctx.uploadInstanceRange(
cursorOffsetInstances(render_cache.rows),
render_cache.cursor_instances.items,
)) {
fallback_to_full_upload = true;
}
}
if (fallback_to_full_upload) {
const pack_result = try repackRowCaches(
alloc,
&render_cache.packed_instances,
render_cache.rows,
render_cache.cursor_instances.items,
);
render_cache.total_instance_count = pack_result.total_instances;
render_cache.layout_dirty = false;
if (render_cache.packed_instances.items.len > 0) {
try ctx.uploadInstances(render_cache.packed_instances.items);
}
}
}
frame_timing.instance_upload_us = usFromTimer(§ion_timer);
section_timer = std.time.Timer.start() catch unreachable;
const baseline_coverage = renderer.coverageVariantParams(.baseline);
ctx.drawCells(
render_cache.total_instance_count,
.{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
default_bg,
baseline_coverage,
) catch |err| switch (err) {
error.OutOfDateKHR => {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
try ctx.recreateSwapchain(buf_w, buf_h);
render_pending = true;
continue;
},
else => return err,
};
frame_timing.gpu_submit_us = usFromTimer(§ion_timer);
frame_ring.push(frame_timing);
// Check for SIGUSR1 stats dump request
if (sigusr1_received.swap(false, .acq_rel)) {
printFrameStats(computeFrameStats(&frame_ring));
}
clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
render_pending = false;
}
// Dump timing stats on exit
printFrameStats(computeFrameStats(&frame_ring));
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
}
fn gridSizeForWindow(window_w: u32, window_h: u32, cell_w: u32, cell_h: u32) GridSize {
const cols = @max(@as(u32, 1), if (cell_w == 0) 1 else window_w / cell_w);
const rows = @max(@as(u32, 1), if (cell_h == 0) 1 else window_h / cell_h);
return .{
.cols = @intCast(cols),
.rows = @intCast(rows),
};
}
fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool {
return ev.action == .press and
ev.modifiers.ctrl and
ev.modifiers.shift and
ev.keysym == c.XKB_KEY_V;
}
fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
return ev.action == .press and
ev.modifiers.ctrl and
ev.modifiers.shift and
ev.keysym == c.XKB_KEY_C;
}
fn copySelectionText(
alloc: std.mem.Allocator,
clipboard: ?*wayland_client.Clipboard,
term: *vt.Terminal,
selection: ?SelectionSpan,
serial: u32,
) !bool {
// Guards fire in order:
// 1. clipboard == null → false (no Wayland clipboard available)
// 2. selection == null → false (no active selection span)
// 3. text.len == 0 → false (selection covers only blank cells)
// Guard 3 can only be reached with a real clipboard; it cannot be unit-tested
// without a live Wayland connection.
const cb = clipboard orelse return false;
const span = selection orelse return false;
const text = try extractSelectedText(alloc, term.render_state.row_data.items(.cells), span);
defer alloc.free(text);
if (text.len == 0) return false;
try cb.setSelectionText(text, serial);
return true;
}
fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 {
const deadline = deadline_ns orelse return null;
const now = std.time.nanoTimestamp();
if (deadline <= now) return 0;
const remaining_ns = deadline - now;
return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms));
}
fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 {
if (render_pending) return 0;
return next_repeat_in_ms orelse -1;
}
fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool {
return terminal_dirty or window_dirty or forced;
}
fn extractSelectedText(
alloc: std.mem.Allocator,
row_data: anytype,
span: SelectionSpan,
) ![]u8 {
const normalized = span.normalized();
if (row_data.len == 0) return try alloc.alloc(u8, 0);
const max_row = row_data.len - 1;
if (normalized.start.row > max_row) return try alloc.alloc(u8, 0);
const visible_end_row = @min(normalized.end.row, max_row);
if (normalized.start.row > visible_end_row) return try alloc.alloc(u8, 0);
var out: std.ArrayListUnmanaged(u8) = .empty;
errdefer out.deinit(alloc);
const start_row: usize = @intCast(normalized.start.row);
const end_row: usize = @intCast(visible_end_row);
var row_idx = start_row;
while (row_idx <= end_row) : (row_idx += 1) {
if (row_idx > start_row) try out.append(alloc, '\n');
try appendSelectedRowText(alloc, &out, row_data[row_idx], normalized, row_idx);
}
return out.toOwnedSlice(alloc);
}
fn appendSelectedRowText(
alloc: std.mem.Allocator,
out: *std.ArrayListUnmanaged(u8),
row_cells: anytype,
span: SelectionSpan,
row_idx: usize,
) !void {
if (row_cells.len == 0) return;
const start_col: usize = if (row_idx == span.start.row)
@intCast(span.start.col)
else
0;
if (start_col >= row_cells.len) return;
var end_col: usize = if (row_idx == span.end.row)
@intCast(span.end.col)
else
row_cells.len - 1;
if (end_col >= row_cells.len) end_col = row_cells.len - 1;
while (end_col >= start_col) {
const cell = row_cells.get(end_col);
if (!isTrailingBlankCell(cell)) break;
if (end_col == 0) return;
end_col -= 1;
}
var col = start_col;
while (col <= end_col) : (col += 1) {
const cell = row_cells.get(col);
try appendSelectedCellText(alloc, out, cell);
}
}
fn isTrailingBlankCell(cell: anytype) bool {
return !cell.raw.hasText() and cell.raw.wide != .spacer_tail and cell.raw.wide != .spacer_head;
}
fn appendSelectedCellText(
alloc: std.mem.Allocator,
out: *std.ArrayListUnmanaged(u8),
cell: anytype,
) !void {
if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) return;
if (!cell.raw.hasText()) {
try out.append(alloc, ' ');
return;
}
try appendCodepoint(alloc, out, cell.raw.codepoint());
if (cell.raw.hasGrapheme()) {
for (cell.grapheme) |cp| {
try appendCodepoint(alloc, out, cp);
}
}
}
fn appendCodepoint(
alloc: std.mem.Allocator,
out: *std.ArrayListUnmanaged(u8),
cp: u21,
) !void {
if (cp == 0) return;
var utf8_buf: [4]u8 = undefined;
const utf8_len = try std.unicode.utf8Encode(cp, &utf8_buf);
try out.appendSlice(alloc, utf8_buf[0..utf8_len]);
}
const BTN_LEFT: u32 = 0x110;
const GridPoint = struct {
col: u32,
row: u32,
};
const SelectionState = struct {
hover: ?GridPoint = null,
anchor: ?GridPoint = null,
active: ?SelectionSpan = null,
committed: ?SelectionSpan = null,
};
fn clampGridPoint(point: GridPoint, cols: u16, rows: u16) ?GridPoint {
if (cols == 0 or rows == 0) return null;
const max_col = @as(u32, cols) - 1;
const max_row = @as(u32, rows) - 1;
return .{
.col = @min(point.col, max_col),
.row = @min(point.row, max_row),
};
}
fn surfacePointToGrid(
surface_x: f64,
surface_y: f64,
cell_w: u32,
cell_h: u32,
cols: u16,
rows: u16,
) ?GridPoint {
if (cell_w == 0 or cell_h == 0 or cols == 0 or rows == 0) return null;
if (surface_x < 0 or surface_y < 0) return null;
const col: u32 = @intFromFloat(@floor(surface_x / @as(f64, @floatFromInt(cell_w))));
const row: u32 = @intFromFloat(@floor(surface_y / @as(f64, @floatFromInt(cell_h))));
if (col >= cols or row >= rows) return null;
return .{ .col = col, .row = row };
}
fn handlePointerSelectionEvent(
state: *SelectionState,
ev: wayland_client.PointerEvent,
cell_w: u32,
cell_h: u32,
cols: u16,
rows: u16,
) void {
switch (ev) {
.motion => |m| {
state.hover = surfacePointToGrid(m.x, m.y, cell_w, cell_h, cols, rows);
if (state.anchor) |anchor| {
if (state.hover) |hover| {
state.active = .{ .start = anchor, .end = hover };
}
}
},
.button_press => |b| {
if (b.button == BTN_LEFT) {
state.committed = null;
if (state.hover) |hover| {
state.anchor = hover;
state.active = .{ .start = hover, .end = hover };
}
}
},
.button_release => |b| {
if (b.button == BTN_LEFT) {
if (state.active) |span| {
state.committed = span;
}
state.active = null;
state.anchor = null;
}
},
.enter => |e| {
state.hover = surfacePointToGrid(e.x, e.y, cell_w, cell_h, cols, rows);
},
.leave => {
state.hover = null;
},
}
}
fn activeSelectionSpan(state: SelectionState) ?SelectionSpan {
return state.active orelse state.committed;
}
fn selectionColors(base: vt.CellColors, selected: bool) vt.CellColors {
if (!selected) return base;
return .{
.fg = .{ 0.08, 0.08, 0.08, 1.0 },
.bg = .{ 0.78, 0.82, 0.88, 1.0 },
};
}
const SelectionSpan = struct {
start: GridPoint,
end: GridPoint,
fn normalized(self: SelectionSpan) SelectionSpan {
if (self.start.row < self.end.row) return self;
if (self.start.row == self.end.row and self.start.col <= self.end.col) return self;
return .{ .start = self.end, .end = self.start };
}
fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
const span = self.normalized();
if (row < span.start.row or row > span.end.row) return false;
if (span.start.row == span.end.row) {
return row == span.start.row and col >= span.start.col and col <= span.end.col;
}
if (row == span.start.row) return col >= span.start.col;
if (row == span.end.row) return col <= span.end.col;
return true;
}
};
fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
if (cols == 0 or rows == 0) return null;
const normalized = span.normalized();
const max_col = @as(u32, cols) - 1;
const max_row = @as(u32, rows) - 1;
if (normalized.start.row > max_row) return null;
const visible_end_row = @min(normalized.end.row, max_row);
if (normalized.start.row > visible_end_row) return null;
var visible_start: ?GridPoint = null;
var visible_end: ?GridPoint = null;
var row = normalized.start.row;
while (row <= visible_end_row) : (row += 1) {
const row_start_col = if (row == normalized.start.row) normalized.start.col else 0;
const row_end_col = if (row == normalized.end.row) @min(normalized.end.col, max_col) else max_col;
if (row_start_col > max_col or row_start_col > row_end_col) continue;
if (visible_start == null) {
visible_start = .{
.col = row_start_col,
.row = row,
};
}
visible_end = .{
.col = row_end_col,
.row = row,
};
}
return if (visible_start) |start_point| .{
.start = start_point,
.end = visible_end.?,
} else null;
}
const FrameTiming = struct {
snapshot_us: u32 = 0,
row_rebuild_us: u32 = 0,
atlas_upload_us: u32 = 0,
instance_upload_us: u32 = 0,
gpu_submit_us: u32 = 0,
fn total(self: FrameTiming) u32 {
return self.snapshot_us +
self.row_rebuild_us +
self.atlas_upload_us +
self.instance_upload_us +
self.gpu_submit_us;
}
};
const FrameTimingRing = struct {
const capacity = 256;
entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
head: usize = 0,
count: usize = 0,
fn push(self: *FrameTimingRing, timing: FrameTiming) void {
const idx = if (self.count < capacity) self.count else self.head;
self.entries[idx] = timing;
if (self.count < capacity) {
self.count += 1;
} else {
self.head = (self.head + 1) % capacity;
}
}
/// Return a slice of valid entries in insertion order.
/// Caller must provide a scratch buffer of `capacity` entries.
fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
if (self.count < capacity) {
return self.entries[0..self.count];
}
// Ring has wrapped — copy from head..end then 0..head
const tail_len = capacity - self.head;
@memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
@memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
return buf[0..capacity];
}
};
const SectionStats = struct {
min: u32 = 0,
avg: u32 = 0,
p99: u32 = 0,
max: u32 = 0,
};
const FrameTimingStats = struct {
snapshot: SectionStats = .{},
row_rebuild: SectionStats = .{},
atlas_upload: SectionStats = .{},
instance_upload: SectionStats = .{},
gpu_submit: SectionStats = .{},
total: SectionStats = .{},
frame_count: usize = 0,
};
fn computeSectionStats(values: []u32) SectionStats {
if (values.len == 0) return .{};
std.mem.sort(u32, values, {}, std.sort.asc(u32));
var sum: u64 = 0;
for (values) |v| sum += v;
const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
return .{
.min = values[0],
.avg = @intCast(sum / values.len),
.p99 = values[p99_idx],
.max = values[values.len - 1],
};
}
fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
if (ring.count == 0) return .{};
var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
const entries = ring.orderedSlice(&ordered_buf);
const n = entries.len;
var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
var total_vals: [FrameTimingRing.capacity]u32 = undefined;
for (entries, 0..) |e, i| {
snapshot_vals[i] = e.snapshot_us;
row_rebuild_vals[i] = e.row_rebuild_us;
atlas_upload_vals[i] = e.atlas_upload_us;
instance_upload_vals[i] = e.instance_upload_us;
gpu_submit_vals[i] = e.gpu_submit_us;
total_vals[i] = e.total();
}
return .{
.snapshot = computeSectionStats(snapshot_vals[0..n]),
.row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
.atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
.instance_upload = computeSectionStats(instance_upload_vals[0..n]),
.gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
.total = computeSectionStats(total_vals[0..n]),
.frame_count = n,
};
}
fn printFrameStats(stats: FrameTimingStats) void {
const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
std.debug.print("----------------------------------------------------\n", .{});
std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
}
var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
fn sigusr1Handler(_: c_int) callconv(.c) void {
sigusr1_received.store(true, .release);
}
fn installSigusr1Handler() void {
const act = std.posix.Sigaction{
.handler = .{ .handler = sigusr1Handler },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.RESTART,
};
std.posix.sigaction(std.posix.SIG.USR1, &act, null);
}
fn usFromTimer(timer: *std.time.Timer) u32 {
const ns = timer.read();
const us = ns / std.time.ns_per_us;
return std.math.cast(u32, us) orelse std.math.maxInt(u32);
}
test "SelectionSpan.normalized orders endpoints in reading order" {
const span = (SelectionSpan{
.start = .{ .col = 7, .row = 4 },
.end = .{ .col = 2, .row = 1 },
}).normalized();
try std.testing.expectEqual(@as(u32, 2), span.start.col);
try std.testing.expectEqual(@as(u32, 1), span.start.row);
try std.testing.expectEqual(@as(u32, 7), span.end.col);
try std.testing.expectEqual(@as(u32, 4), span.end.row);
}
test "SelectionSpan.containsCell includes the normalized endpoints" {
const span = SelectionSpan{
.start = .{ .col = 3, .row = 2 },
.end = .{ .col = 1, .row = 1 },
};
try std.testing.expect(span.containsCell(1, 1));
try std.testing.expect(span.containsCell(2, 1));
try std.testing.expect(span.containsCell(0, 2));
try std.testing.expect(span.containsCell(3, 2));
try std.testing.expect(!span.containsCell(0, 0));
try std.testing.expect(!span.containsCell(4, 2));
}
test "SelectionSpan.containsCell includes a same-cell span" {
const span = SelectionSpan{
.start = .{ .col = 5, .row = 5 },
.end = .{ .col = 5, .row = 5 },
};
try std.testing.expect(span.containsCell(5, 5));
try std.testing.expect(!span.containsCell(4, 5));
try std.testing.expect(!span.containsCell(5, 4));
}
test "clampSelectionSpan preserves a same-cell visible span" {
const clamped = clampSelectionSpan(.{
.start = .{ .col = 5, .row = 5 },
.end = .{ .col = 5, .row = 5 },
}, 80, 24).?;
try std.testing.expectEqual(@as(u32, 5), clamped.start.col);
try std.testing.expectEqual(@as(u32, 5), clamped.start.row);
try std.testing.expectEqual(@as(u32, 5), clamped.end.col);
try std.testing.expectEqual(@as(u32, 5), clamped.end.row);
try std.testing.expect(clamped.containsCell(5, 5));
}
test "clampSelectionSpan clears offscreen spans and trims resized spans" {
try std.testing.expect(clampSelectionSpan(.{
.start = .{ .col = 5, .row = 30 },
.end = .{ .col = 10, .row = 40 },
}, 80, 24) == null);
const clamped = clampSelectionSpan(.{
.start = .{ .col = 78, .row = 22 },
.end = .{ .col = 99, .row = 30 },
}, 80, 24).?;
try std.testing.expectEqual(@as(u32, 78), clamped.start.col);
try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
}
test "clampSelectionSpan starts the visible span on the next row when the first row is clipped on the right" {
const clamped = clampSelectionSpan(.{
.start = .{ .col = 5, .row = 0 },
.end = .{ .col = 2, .row = 2 },
}, 4, 3).?;
try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
try std.testing.expectEqual(@as(u32, 1), clamped.start.row);
try std.testing.expectEqual(@as(u32, 2), clamped.end.col);
try std.testing.expectEqual(@as(u32, 2), clamped.end.row);
}
test "clampSelectionSpan extends the clipped bottom row to the last visible column" {
const clamped = clampSelectionSpan(.{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 2, .row = 5 },
}, 4, 2).?;
try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
try std.testing.expectEqual(@as(u32, 3), clamped.end.col);
try std.testing.expectEqual(@as(u32, 1), clamped.end.row);
}
test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
const clamped = clampSelectionSpan(.{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 120, .row = 80 },
}, 1, 1).?;
try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
try std.testing.expect(clamped.containsCell(0, 0));
}
test "SelectionState starts drag on left-button press and commits on release" {
var state = SelectionState{};
handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 24.0, .y = 16.0 } }, 8, 16, 80, 24);
handlePointerSelectionEvent(&state, .{ .button_press = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24);
handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 56.0, .y = 16.0 } }, 8, 16, 80, 24);
handlePointerSelectionEvent(&state, .{ .button_release = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24);
try std.testing.expect(state.active == null);
try std.testing.expect(state.committed != null);
}
test "selectionColors overrides terminal colors for selected cells" {
const selected = selectionColors(.{
.fg = .{ 1.0, 1.0, 1.0, 1.0 },
.bg = .{ 0.0, 0.0, 0.0, 1.0 },
}, true);
try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg);
try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg);
}
const ComparisonVariant = struct {
label: []const u8,
coverage: [2]f32,
};
fn comparisonVariants() [4]ComparisonVariant {
return .{
.{ .label = "baseline", .coverage = renderer.coverageVariantParams(.baseline) },
.{ .label = "mild", .coverage = renderer.coverageVariantParams(.mild) },
.{ .label = "medium", .coverage = renderer.coverageVariantParams(.medium) },
.{ .label = "crisp", .coverage = renderer.coverageVariantParams(.crisp) },
};
}
fn comparisonSpecimenLines() []const []const u8 {
return &.{
"abcdefghijklmnopqrstuvwxyz",
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"0123456789",
"{}[]()/\\|,.;:_-=+",
"~/code/rad/waystty $ zig build test",
};
}
fn comparisonPanelOrigins(panel_cols: u32, top_margin_rows: u32) [4][2]f32 {
var origins = [_][2]f32{.{ 0.0, @floatFromInt(top_margin_rows) }} ** 4;
var idx: usize = 0;
while (idx < origins.len) : (idx += 1) {
origins[idx] = .{
@floatFromInt(idx * @as(usize, panel_cols)),
@floatFromInt(top_margin_rows),
};
}
return origins;
}
const ComparisonPanelDraw = struct {
instance_offset_instances: u32,
instance_count: u32,
coverage: [2]f32,
origin_col: u32,
};
const TextCoverageCompareScene = struct {
instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
panel_draws: [4]ComparisonPanelDraw,
panel_cols: u32,
window_cols: u32,
window_rows: u32,
fn deinit(self: *TextCoverageCompareScene, alloc: std.mem.Allocator) void {
self.instances.deinit(alloc);
}
};
fn comparisonPanelCols(lines: []const []const u8) u32 {
var max_cols: u32 = 0;
for (lines) |line| {
max_cols = @max(max_cols, @as(u32, @intCast(line.len)));
}
return max_cols + 4;
}
fn comparisonVisibleGlyphCount(lines: []const []const u8) u32 {
var count: u32 = 0;
for (lines) |line| {
for (line) |char| {
if (char != ' ') count += 1;
}
}
return count;
}
fn buildTextCoverageCompareScene(
alloc: std.mem.Allocator,
face: *font.Face,
atlas: *font.Atlas,
) !TextCoverageCompareScene {
const specimen_lines = comparisonSpecimenLines();
const variants = comparisonVariants();
const top_margin_rows: u32 = 2;
const panel_cols = comparisonPanelCols(specimen_lines);
const panel_origins = comparisonPanelOrigins(panel_cols, top_margin_rows);
const panel_glyph_count = comparisonVisibleGlyphCount(specimen_lines);
var total_glyph_count = panel_glyph_count * @as(u32, @intCast(variants.len));
for (variants) |variant| {
total_glyph_count += comparisonVisibleGlyphCount(&.{variant.label});
}
var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
errdefer instances.deinit(alloc);
try instances.ensureTotalCapacity(alloc, total_glyph_count);
const baseline = face.baseline();
const fg = [4]f32{ 1.0, 1.0, 1.0, 1.0 };
const bg = [4]f32{ 0.0, 0.0, 0.0, 1.0 };
var panel_draws: [4]ComparisonPanelDraw = undefined;
for (variants, 0..) |variant, panel_idx| {
const origin = panel_origins[panel_idx];
const instance_offset_instances: u32 = @intCast(instances.items.len);
const label_row = origin[1] - @as(f32, @floatFromInt(top_margin_rows));
for (variant.label, 0..) |char, col_idx| {
if (char == ' ') continue;
const glyph_uv = try atlas.getOrInsert(face, char);
try instances.append(alloc, .{
.cell_pos = .{
origin[0] + @as(f32, @floatFromInt(col_idx)),
label_row,
},
.glyph_size = .{
@floatFromInt(glyph_uv.width),
@floatFromInt(glyph_uv.height),
},
.glyph_bearing = .{
@floatFromInt(glyph_uv.bearing_x),
glyphTopOffset(baseline, glyph_uv.bearing_y),
},
.uv_rect = .{
glyph_uv.u0,
glyph_uv.v0,
glyph_uv.u1,
glyph_uv.v1,
},
.fg = fg,
.bg = bg,
});
}
for (specimen_lines, 0..) |line, row_idx| {
for (line, 0..) |char, col_idx| {
if (char == ' ') continue;
const glyph_uv = try atlas.getOrInsert(face, char);
try instances.append(alloc, .{
.cell_pos = .{
origin[0] + @as(f32, @floatFromInt(col_idx)),
origin[1] + @as(f32, @floatFromInt(row_idx)),
},
.glyph_size = .{
@floatFromInt(glyph_uv.width),
@floatFromInt(glyph_uv.height),
},
.glyph_bearing = .{
@floatFromInt(glyph_uv.bearing_x),
glyphTopOffset(baseline, glyph_uv.bearing_y),
},
.uv_rect = .{
glyph_uv.u0,
glyph_uv.v0,
glyph_uv.u1,
glyph_uv.v1,
},
.fg = fg,
.bg = bg,
});
}
}
panel_draws[panel_idx] = .{
.instance_offset_instances = instance_offset_instances,
.instance_count = @as(u32, @intCast(instances.items.len)) - instance_offset_instances,
.coverage = variant.coverage,
.origin_col = @as(u32, @intCast(panel_idx)) * panel_cols,
};
}
return .{
.instances = instances,
.panel_draws = panel_draws,
.panel_cols = panel_cols,
.window_cols = panel_cols * @as(u32, @intCast(variants.len)),
.window_rows = top_margin_rows + @as(u32, @intCast(specimen_lines.len)) + 2,
};
}
fn drawTextCoverageCompareFrame(
ctx: *renderer.Context,
scene: *const TextCoverageCompareScene,
cell_w_px: u32,
cell_h_px: u32,
clear_color: [4]f32,
) !void {
_ = try ctx.vkd.waitForFences(
ctx.device,
1,
@ptrCast(&ctx.in_flight_fence),
.true,
std.math.maxInt(u64),
);
try ctx.vkd.resetFences(ctx.device, 1, @ptrCast(&ctx.in_flight_fence));
const acquire = ctx.vkd.acquireNextImageKHR(
ctx.device,
ctx.swapchain,
std.math.maxInt(u64),
ctx.image_available,
.null_handle,
) catch |err| switch (err) {
error.OutOfDateKHR => return error.OutOfDateKHR,
else => return err,
};
if (acquire.result == .suboptimal_khr) return error.OutOfDateKHR;
const image_index = acquire.image_index;
try ctx.vkd.resetCommandBuffer(ctx.command_buffer, .{});
try ctx.vkd.beginCommandBuffer(ctx.command_buffer, &vk.CommandBufferBeginInfo{
.flags = .{ .one_time_submit_bit = true },
});
const clear_value = vk.ClearValue{ .color = .{ .float_32 = clear_color } };
ctx.vkd.cmdBeginRenderPass(ctx.command_buffer, &vk.RenderPassBeginInfo{
.render_pass = ctx.render_pass,
.framebuffer = ctx.framebuffers[image_index],
.render_area = .{
.offset = .{ .x = 0, .y = 0 },
.extent = ctx.swapchain_extent,
},
.clear_value_count = 1,
.p_clear_values = @ptrCast(&clear_value),
}, .@"inline");
ctx.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);
const viewport = vk.Viewport{
.x = 0.0,
.y = 0.0,
.width = @floatFromInt(ctx.swapchain_extent.width),
.height = @floatFromInt(ctx.swapchain_extent.height),
.min_depth = 0.0,
.max_depth = 1.0,
};
ctx.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));
ctx.vkd.cmdBindDescriptorSets(
ctx.command_buffer,
.graphics,
ctx.pipeline_layout,
0,
1,
@ptrCast(&ctx.descriptor_set),
0,
null,
);
const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
const panel_width_px = scene.panel_cols * cell_w_px;
for (scene.panel_draws) |panel_draw| {
const scissor_x_px = panel_draw.origin_col * cell_w_px;
if (scissor_x_px >= ctx.swapchain_extent.width) continue;
const scissor = vk.Rect2D{
.offset = .{ .x = @intCast(scissor_x_px), .y = 0 },
.extent = .{
.width = @min(panel_width_px, ctx.swapchain_extent.width - scissor_x_px),
.height = ctx.swapchain_extent.height,
},
};
ctx.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));
const push_constants = renderer.PushConstants{
.viewport_size = .{
@floatFromInt(ctx.swapchain_extent.width),
@floatFromInt(ctx.swapchain_extent.height),
},
.cell_size = .{
@floatFromInt(cell_w_px),
@floatFromInt(cell_h_px),
},
.coverage_params = panel_draw.coverage,
};
ctx.vkd.cmdPushConstants(
ctx.command_buffer,
ctx.pipeline_layout,
.{ .vertex_bit = true, .fragment_bit = true },
0,
@sizeOf(renderer.PushConstants),
@ptrCast(&push_constants),
);
const offsets = [_]vk.DeviceSize{
0,
@as(vk.DeviceSize, panel_draw.instance_offset_instances) * @sizeOf(renderer.Instance),
};
ctx.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);
ctx.vkd.cmdDraw(ctx.command_buffer, 6, panel_draw.instance_count, 0, 0);
}
ctx.vkd.cmdEndRenderPass(ctx.command_buffer);
try ctx.vkd.endCommandBuffer(ctx.command_buffer);
const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
try ctx.vkd.queueSubmit(ctx.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
.wait_semaphore_count = 1,
.p_wait_semaphores = @ptrCast(&ctx.image_available),
.p_wait_dst_stage_mask = @ptrCast(&wait_stage),
.command_buffer_count = 1,
.p_command_buffers = @ptrCast(&ctx.command_buffer),
.signal_semaphore_count = 1,
.p_signal_semaphores = @ptrCast(&ctx.render_finished),
}), ctx.in_flight_fence);
const present_result = ctx.vkd.queuePresentKHR(ctx.present_queue, &vk.PresentInfoKHR{
.wait_semaphore_count = 1,
.p_wait_semaphores = @ptrCast(&ctx.render_finished),
.swapchain_count = 1,
.p_swapchains = @ptrCast(&ctx.swapchain),
.p_image_indices = @ptrCast(&image_index),
}) catch |err| switch (err) {
error.OutOfDateKHR => return error.OutOfDateKHR,
else => return err,
};
if (present_result == .suboptimal_khr) return error.OutOfDateKHR;
}
const RowRefreshState = enum {
full,
partial,
};
const CursorRefreshContext = struct {
old_row: ?usize,
new_row: ?usize,
old_col: ?usize,
new_col: ?usize,
old_visible: bool,
new_visible: bool,
};
const RowRefreshContext = struct {
cursor: CursorRefreshContext,
};
const RowRefreshPlan = struct {
full_rebuild: bool,
cursor_rebuild: bool,
rows_to_rebuild: std.StaticBitSet(256),
};
fn planRowRefresh(
state: RowRefreshState,
dirty_rows: []const bool,
ctx: RowRefreshContext,
) RowRefreshPlan {
var rows_to_rebuild = std.StaticBitSet(256).initEmpty();
const full_rebuild = state == .full or dirty_rows.len > rows_to_rebuild.capacity();
const cursor_rebuild = full_rebuild or
cursorNeedsRebuild(ctx.cursor) or
cursorTouchesDirtyRow(dirty_rows, ctx.cursor);
if (!full_rebuild) {
var row_idx: usize = 0;
while (row_idx < dirty_rows.len) : (row_idx += 1) {
if (dirty_rows[row_idx]) rows_to_rebuild.set(row_idx);
}
}
return .{
.full_rebuild = full_rebuild,
.cursor_rebuild = cursor_rebuild,
.rows_to_rebuild = rows_to_rebuild,
};
}
fn cursorNeedsRebuild(cursor: CursorRefreshContext) bool {
return cursor.old_row != cursor.new_row or
cursor.old_col != cursor.new_col or
cursor.old_visible != cursor.new_visible;
}
fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext) bool {
if (cursor.old_row) |row| {
if (row < dirty_rows.len and dirty_rows[row]) return true;
}
if (cursor.new_row) |row| {
if (row < dirty_rows.len and dirty_rows[row]) return true;
}
return false;
}
fn appendCellInstances(
alloc: std.mem.Allocator,
instances: *std.ArrayListUnmanaged(renderer.Instance),
row_idx: u32,
col_idx: u32,
cell_w: u32,
cell_h: u32,
baseline: u32,
glyph_uv: ?font.GlyphUV,
bg_uv: font.GlyphUV,
colors: vt.CellColors,
default_bg: [4]f32,
) !void {
if (!std.meta.eql(colors.bg, default_bg)) {
try instances.append(alloc, .{
.cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
.glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
.glyph_bearing = .{ 0, 0 },
.uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
.fg = colors.bg,
.bg = colors.bg,
});
}
const uv = glyph_uv orelse return;
try instances.append(alloc, .{
.cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
.glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
.glyph_bearing = .{
@floatFromInt(uv.bearing_x),
glyphTopOffset(baseline, uv.bearing_y),
},
.uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
.fg = colors.fg,
.bg = colors.bg,
});
}
fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}
fn encodeKeyboardEvent(
term: *const vt.Terminal,
ev: wayland_client.KeyboardEvent,
buf: []u8,
) !?[]const u8 {
const key = mapKeysymToInputKey(ev.keysym) orelse return null;
const encoded = try term.encodeKey(
key,
.{
.shift = ev.modifiers.shift,
.ctrl = ev.modifiers.ctrl,
.alt = ev.modifiers.alt,
.super = ev.modifiers.super,
},
switch (ev.action) {
.press => .press,
.release => .release,
.repeat => .repeat,
},
buf,
);
if (encoded.len == 0) return null;
return encoded;
}
fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey {
return switch (keysym) {
c.XKB_KEY_Up => .arrow_up,
c.XKB_KEY_Down => .arrow_down,
c.XKB_KEY_Left => .arrow_left,
c.XKB_KEY_Right => .arrow_right,
c.XKB_KEY_Home => .home,
c.XKB_KEY_End => .end,
c.XKB_KEY_Page_Up => .page_up,
c.XKB_KEY_Page_Down => .page_down,
c.XKB_KEY_Insert => .insert,
c.XKB_KEY_Delete => .delete,
c.XKB_KEY_F1 => .f1,
c.XKB_KEY_F2 => .f2,
c.XKB_KEY_F3 => .f3,
c.XKB_KEY_F4 => .f4,
c.XKB_KEY_F5 => .f5,
c.XKB_KEY_F6 => .f6,
c.XKB_KEY_F7 => .f7,
c.XKB_KEY_F8 => .f8,
c.XKB_KEY_F9 => .f9,
c.XKB_KEY_F10 => .f10,
c.XKB_KEY_F11 => .f11,
c.XKB_KEY_F12 => .f12,
else => null,
};
}
test "event loop waits indefinitely when idle and wakes for imminent repeat" {
try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false));
try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true));
try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false));
}
test "event loop redraws only when terminal or window state changed" {
try std.testing.expect(shouldRenderFrame(true, false, false));
try std.testing.expect(shouldRenderFrame(false, true, false));
try std.testing.expect(shouldRenderFrame(false, false, true));
try std.testing.expect(!shouldRenderFrame(false, false, false));
}
test "planRowRefresh requests full rebuild for full dirty state" {
const plan = planRowRefresh(.full, &.{ false, true, false }, .{
.cursor = .{
.old_row = null,
.new_row = null,
.old_col = null,
.new_col = null,
.old_visible = false,
.new_visible = false,
},
});
try std.testing.expect(plan.full_rebuild);
try std.testing.expect(plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}
test "planRowRefresh selects only dirty rows for partial state" {
const plan = planRowRefresh(.partial, &.{ false, true, false, true }, .{
.cursor = .{
.old_row = null,
.new_row = null,
.old_col = null,
.new_col = null,
.old_visible = false,
.new_visible = false,
},
});
try std.testing.expect(!plan.full_rebuild);
try std.testing.expect(!plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 2), plan.rows_to_rebuild.count());
try std.testing.expect(plan.rows_to_rebuild.isSet(1));
try std.testing.expect(plan.rows_to_rebuild.isSet(3));
try std.testing.expect(!plan.rows_to_rebuild.isSet(0));
try std.testing.expect(!plan.rows_to_rebuild.isSet(2));
}
test "planRowRefresh handles cursor-only updates without unrelated rows" {
const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
.cursor = .{
.old_row = 1,
.new_row = 2,
.old_col = 4,
.new_col = 4,
.old_visible = true,
.new_visible = true,
},
});
try std.testing.expect(!plan.full_rebuild);
try std.testing.expect(plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}
test "planRowRefresh forces full rebuild when dirty rows exceed fixed capacity" {
var dirty_rows: [257]bool = .{false} ** 257;
dirty_rows[256] = true;
const plan = planRowRefresh(.partial, dirty_rows[0..], .{
.cursor = .{
.old_row = null,
.new_row = null,
.old_col = null,
.new_col = null,
.old_visible = true,
.new_visible = true,
},
});
try std.testing.expect(plan.full_rebuild);
try std.testing.expect(plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}
test "planRowRefresh rebuilds cursor when only column changes on same row" {
const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
.cursor = .{
.old_row = 2,
.new_row = 2,
.old_col = 1,
.new_col = 5,
.old_visible = true,
.new_visible = true,
},
});
try std.testing.expect(!plan.full_rebuild);
try std.testing.expect(plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}
test "planRowRefresh rebuilds cursor when its row is dirty without cursor movement" {
const plan = planRowRefresh(.partial, &.{ false, true, false }, .{
.cursor = .{
.old_row = 1,
.new_row = 1,
.old_col = 4,
.new_col = 4,
.old_visible = true,
.new_visible = true,
},
});
try std.testing.expect(!plan.full_rebuild);
try std.testing.expect(plan.cursor_rebuild);
try std.testing.expectEqual(@as(usize, 1), plan.rows_to_rebuild.count());
}
test "repackRowCaches assigns contiguous offsets" {
var rows = [_]RowInstanceCache{
.{
.instances = try makeTestInstances(std.testing.allocator, 2),
.gpu_offset_instances = 99,
.gpu_len_instances = 0,
},
.{
.instances = try makeTestInstances(std.testing.allocator, 3),
.gpu_offset_instances = 99,
.gpu_len_instances = 0,
},
};
defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);
var packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
defer packed_instances.deinit(std.testing.allocator);
var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
defer cursor_instances.deinit(std.testing.allocator);
rows[0].instances.items[0].cell_pos[1] = 10.0;
rows[0].instances.items[1].cell_pos[1] = 10.0;
rows[1].instances.items[0].cell_pos[1] = 20.0;
rows[1].instances.items[1].cell_pos[1] = 20.0;
rows[1].instances.items[2].cell_pos[1] = 20.0;
cursor_instances.items[0].cell_pos[0] = 99.0;
cursor_instances.items[0].cell_pos[1] = 99.0;
const packed_result = try repackRowCaches(
std.testing.allocator,
&packed_instances,
&rows,
cursor_instances.items,
);
try std.testing.expectEqual(@as(u32, 0), rows[0].gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 2), rows[0].gpu_len_instances);
try std.testing.expectEqual(@as(u32, 2), rows[1].gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 3), rows[1].gpu_len_instances);
try std.testing.expectEqual(@as(u32, 6), packed_result.total_instances);
try std.testing.expectEqual(@as(u32, 5), packed_result.cursor_offset_instances);
try std.testing.expectEqual(@as(u32, 1), packed_result.cursor_len_instances);
try std.testing.expectEqual(@as(usize, 6), packed_instances.items.len);
try std.testing.expectEqualDeep([2]f32{ 0.0, 10.0 }, packed_instances.items[0].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 1.0, 10.0 }, packed_instances.items[1].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 0.0, 20.0 }, packed_instances.items[2].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 1.0, 20.0 }, packed_instances.items[3].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 2.0, 20.0 }, packed_instances.items[4].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 99.0, 99.0 }, packed_instances.items[5].cell_pos);
}
test "markLayoutDirtyOnLenChange returns true when row length changes" {
try std.testing.expect(markLayoutDirtyOnLenChange(2, 3));
try std.testing.expect(!markLayoutDirtyOnLenChange(3, 3));
}
test "repackRowCaches keeps cursor span explicit for empty and non-empty cursor instances" {
var rows = [_]RowInstanceCache{
.{
.instances = try makeTestInstances(std.testing.allocator, 1),
},
};
defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);
var packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
defer packed_instances.deinit(std.testing.allocator);
const empty_cursor_result = try repackRowCaches(std.testing.allocator, &packed_instances, &rows, &.{});
try std.testing.expectEqual(@as(u32, 1), empty_cursor_result.total_instances);
try std.testing.expectEqual(@as(u32, 1), empty_cursor_result.cursor_offset_instances);
try std.testing.expectEqual(@as(u32, 0), empty_cursor_result.cursor_len_instances);
var cursor_instances = try makeTestInstances(std.testing.allocator, 2);
defer cursor_instances.deinit(std.testing.allocator);
cursor_instances.items[0].cell_pos[0] = 7.0;
cursor_instances.items[0].cell_pos[1] = 8.0;
cursor_instances.items[1].cell_pos[0] = 9.0;
cursor_instances.items[1].cell_pos[1] = 10.0;
const non_empty_cursor_result = try repackRowCaches(
std.testing.allocator,
&packed_instances,
&rows,
cursor_instances.items,
);
try std.testing.expectEqual(@as(u32, 3), non_empty_cursor_result.total_instances);
try std.testing.expectEqual(@as(u32, 1), non_empty_cursor_result.cursor_offset_instances);
try std.testing.expectEqual(@as(u32, 2), non_empty_cursor_result.cursor_len_instances);
try std.testing.expectEqual(@as(usize, 3), packed_instances.items.len);
try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, packed_instances.items[0].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 7.0, 8.0 }, packed_instances.items[1].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 9.0, 10.0 }, packed_instances.items[2].cell_pos);
}
test "rebuildCursorInstances invalidates packed cursor state when count is unchanged" {
var cache = RenderCache.empty;
defer cache.deinit(std.testing.allocator);
cache.cursor_instances = try makeTestInstances(std.testing.allocator, 1);
cache.cursor_instances.items[0].cell_pos = .{ 99.0, 99.0 };
try cache.packed_instances.append(std.testing.allocator, .{
.cell_pos = .{ 11.0, 11.0 },
.glyph_size = .{ 1.0, 1.0 },
.glyph_bearing = .{ 0.0, 0.0 },
.uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
.fg = .{ 0.0, 0.0, 0.0, 0.0 },
.bg = .{ 0.0, 0.0, 0.0, 0.0 },
});
cache.total_instance_count = 4;
cache.layout_dirty = false;
var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
defer cursor_instances.deinit(std.testing.allocator);
cursor_instances.items[0].cell_pos = .{ 4.0, 7.0 };
const rebuilt = try cache.rebuildCursorInstances(std.testing.allocator, cursor_instances.items);
try std.testing.expect(!rebuilt.len_changed);
try std.testing.expect(rebuilt.packed_invalidated);
try std.testing.expectEqual(@as(usize, 1), cache.cursor_instances.items.len);
try std.testing.expectEqualDeep([2]f32{ 4.0, 7.0 }, cache.cursor_instances.items[0].cell_pos);
try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
try std.testing.expect(cache.layout_dirty);
}
test "rebuildCursorInstances invalidates packed cursor state when cursor instance count changes" {
var cache = RenderCache.empty;
defer cache.deinit(std.testing.allocator);
cache.cursor_instances = try makeTestInstances(std.testing.allocator, 1);
try cache.packed_instances.append(std.testing.allocator, .{
.cell_pos = .{ 13.0, 13.0 },
.glyph_size = .{ 1.0, 1.0 },
.glyph_bearing = .{ 0.0, 0.0 },
.uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
.fg = .{ 0.0, 0.0, 0.0, 0.0 },
.bg = .{ 0.0, 0.0, 0.0, 0.0 },
});
cache.total_instance_count = 4;
cache.layout_dirty = false;
const rebuilt = try cache.rebuildCursorInstances(std.testing.allocator, &.{});
try std.testing.expect(rebuilt.len_changed);
try std.testing.expect(rebuilt.packed_invalidated);
try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
try std.testing.expect(cache.layout_dirty);
}
test "rebuildRowInstances emits expected instances for a colored glyph row" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b[31;44mA\x1b[0m");
try term.snapshot();
var lookup = try font.lookupConfiguredFont(std.testing.allocator);
defer lookup.deinit(std.testing.allocator);
var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
defer face.deinit();
var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
defer atlas.deinit();
var cache = RowInstanceCache{};
defer cache.instances.deinit(std.testing.allocator);
const row_cells = term.render_state.row_data.get(0).cells;
const default_bg = term.backgroundColor();
const rebuilt = try rebuildRowInstances(
std.testing.allocator,
&cache,
0,
row_cells,
term,
&face,
&atlas,
face.cellWidth(),
face.cellHeight(),
face.baseline(),
default_bg,
atlas.cursorUV(),
null,
);
try std.testing.expect(rebuilt.len_changed);
try std.testing.expectEqual(@as(usize, 2), cache.instances.items.len);
const colors = term.cellColors(row_cells.get(0));
try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[0].cell_pos);
try std.testing.expectEqualDeep([2]f32{ @floatFromInt(face.cellWidth()), @floatFromInt(face.cellHeight()) }, cache.instances.items[0].glyph_size);
try std.testing.expectEqualDeep(colors.bg, cache.instances.items[0].fg);
try std.testing.expectEqualDeep(colors.bg, cache.instances.items[0].bg);
try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[1].cell_pos);
try std.testing.expectEqualDeep(colors.fg, cache.instances.items[1].fg);
try std.testing.expectEqualDeep(colors.bg, cache.instances.items[1].bg);
}
test "rebuildRowInstances replaces stale cached contents without layout dirtiness when count is unchanged" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b[31;44mA\x1b[0m");
try term.snapshot();
var lookup = try font.lookupConfiguredFont(std.testing.allocator);
defer lookup.deinit(std.testing.allocator);
var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
defer face.deinit();
var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
defer atlas.deinit();
var cache = RowInstanceCache{};
defer cache.instances.deinit(std.testing.allocator);
cache.gpu_offset_instances = 17;
cache.gpu_len_instances = 29;
try cache.instances.append(std.testing.allocator, .{
.cell_pos = .{ 99.0, 99.0 },
.glyph_size = .{ 1.0, 1.0 },
.glyph_bearing = .{ 0.0, 0.0 },
.uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
.fg = .{ 0.0, 0.0, 0.0, 0.0 },
.bg = .{ 0.0, 0.0, 0.0, 0.0 },
});
try cache.instances.append(std.testing.allocator, .{
.cell_pos = .{ 98.0, 98.0 },
.glyph_size = .{ 1.0, 1.0 },
.glyph_bearing = .{ 0.0, 0.0 },
.uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
.fg = .{ 0.0, 0.0, 0.0, 0.0 },
.bg = .{ 0.0, 0.0, 0.0, 0.0 },
});
const row_cells = term.render_state.row_data.get(0).cells;
const rebuilt = try rebuildRowInstances(
std.testing.allocator,
&cache,
0,
row_cells,
term,
&face,
&atlas,
face.cellWidth(),
face.cellHeight(),
face.baseline(),
term.backgroundColor(),
atlas.cursorUV(),
null,
);
try std.testing.expect(!rebuilt.len_changed);
try std.testing.expectEqual(@as(usize, 2), cache.instances.items.len);
try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[0].cell_pos);
try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[1].cell_pos);
try std.testing.expectEqualDeep([2]f32{ @floatFromInt(face.cellWidth()), @floatFromInt(face.cellHeight()) }, cache.instances.items[0].glyph_size);
try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[0].fg);
try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[0].bg);
try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).fg, cache.instances.items[1].fg);
try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[1].bg);
try std.testing.expectEqual(@as(u32, 0), cache.gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 0), cache.gpu_len_instances);
}
test "RenderCache resizeRows preserves surviving row caches" {
var cache = RenderCache.empty;
defer cache.deinit(std.testing.allocator);
try cache.resizeRows(std.testing.allocator, 2);
cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
cache.rows[0].gpu_offset_instances = 17;
cache.rows[0].gpu_len_instances = 1;
try cache.resizeRows(std.testing.allocator, 3);
try std.testing.expectEqual(@as(usize, 3), cache.rows.len);
try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
try std.testing.expectEqual(@as(usize, 0), cache.rows[2].instances.items.len);
try cache.resizeRows(std.testing.allocator, 1);
try std.testing.expectEqual(@as(usize, 1), cache.rows.len);
try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
}
test "RenderCache deinit resets fields after releasing row storage" {
var cache = RenderCache.empty;
try cache.resizeRows(std.testing.allocator, 2);
cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 2);
cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 1);
var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
defer cursor_instances.deinit(std.testing.allocator);
try cache.cursor_instances.append(std.testing.allocator, cursor_instances.items[0]);
var packed_instances = try makeTestInstances(std.testing.allocator, 1);
defer packed_instances.deinit(std.testing.allocator);
try cache.packed_instances.append(std.testing.allocator, packed_instances.items[0]);
cache.total_instance_count = 3;
cache.layout_dirty = false;
cache.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 0), cache.rows.len);
try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
try std.testing.expect(cache.layout_dirty);
}
test "RenderCache resizeRows to zero clears derived state after live row data" {
var cache = RenderCache.empty;
try cache.resizeRows(std.testing.allocator, 2);
cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 2);
var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
defer cursor_instances.deinit(std.testing.allocator);
try cache.cursor_instances.append(std.testing.allocator, cursor_instances.items[0]);
var packed_instances = try makeTestInstances(std.testing.allocator, 1);
defer packed_instances.deinit(std.testing.allocator);
try cache.packed_instances.append(std.testing.allocator, packed_instances.items[0]);
cache.total_instance_count = 3;
cache.layout_dirty = false;
try cache.resizeRows(std.testing.allocator, 0);
try std.testing.expectEqual(@as(usize, 0), cache.rows.len);
try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
try std.testing.expect(cache.layout_dirty);
cache.deinit(std.testing.allocator);
}
test "RenderCache resizeRows truncates populated tail rows and preserves prefix only" {
var cache = RenderCache.empty;
defer cache.deinit(std.testing.allocator);
try cache.resizeRows(std.testing.allocator, 3);
cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 2);
cache.rows[2].instances = try makeTestInstances(std.testing.allocator, 3);
cache.rows[0].gpu_offset_instances = 10;
cache.rows[1].gpu_offset_instances = 20;
cache.rows[2].gpu_offset_instances = 30;
try cache.resizeRows(std.testing.allocator, 1);
try std.testing.expectEqual(@as(usize, 1), cache.rows.len);
try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
try std.testing.expect(cache.layout_dirty);
}
test "applyRenderPlan requests full upload when layout changes" {
const result = applyRenderPlan(.{
.layout_dirty = true,
.rows_rebuilt = 1,
.cursor_rebuilt = false,
});
try std.testing.expect(result.full_upload);
try std.testing.expect(!result.partial_upload);
}
test "clearConsumedDirtyFlags clears only consumed partial rows after successful refresh" {
var dirty_rows = [_]bool{ true, false, true, false };
const plan = RowRefreshPlan{
.full_rebuild = false,
.cursor_rebuild = false,
.rows_to_rebuild = blk: {
var rows = std.StaticBitSet(256).initEmpty();
rows.set(0);
rows.set(2);
break :blk rows;
},
};
var render_dirty: vt.RenderDirty = .partial;
clearConsumedDirtyFlags(&render_dirty, dirty_rows[0..], plan);
try std.testing.expectEqual(@as(@TypeOf(render_dirty), .false), render_dirty);
try std.testing.expectEqualSlices(bool, &.{ false, false, false, false }, dirty_rows[0..]);
}
test "font module no longer exposes lookupMonospace compatibility wrapper" {
try std.testing.expect(!@hasDecl(font, "lookupMonospace"));
}
const RowInstanceCache = struct {
instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
gpu_offset_instances: u32 = 0,
gpu_len_instances: u32 = 0,
fn deinit(self: *RowInstanceCache, alloc: std.mem.Allocator) void {
self.instances.deinit(alloc);
self.* = .{};
}
};
const RenderCache = struct {
rows: []RowInstanceCache = &.{},
cursor_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
total_instance_count: u32 = 0,
layout_dirty: bool = true,
const empty: RenderCache = .{};
fn resizeRows(self: *RenderCache, alloc: std.mem.Allocator, row_count: usize) !void {
if (self.rows.len == row_count) return;
const old_rows = self.rows;
if (row_count == 0) {
if (old_rows.len > 0) {
var row_idx: usize = 0;
while (row_idx < old_rows.len) : (row_idx += 1) {
old_rows[row_idx].deinit(alloc);
}
alloc.free(old_rows);
}
self.rows = &.{};
self.invalidateAfterResize();
return;
}
var new_rows = try alloc.alloc(RowInstanceCache, row_count);
for (new_rows) |*row| row.* = .{};
const copy_len = @min(old_rows.len, row_count);
var row_idx: usize = 0;
while (row_idx < copy_len) : (row_idx += 1) {
// Preserve only the surviving prefix by moving ownership row-by-row.
new_rows[row_idx] = old_rows[row_idx];
old_rows[row_idx] = .{};
}
while (row_idx < old_rows.len) : (row_idx += 1) {
old_rows[row_idx].deinit(alloc);
}
if (old_rows.len > 0) {
alloc.free(old_rows);
}
self.rows = new_rows;
self.invalidateAfterResize();
}
fn deinit(self: *RenderCache, alloc: std.mem.Allocator) void {
for (self.rows) |*row| row.deinit(alloc);
if (self.rows.len > 0) {
alloc.free(self.rows);
}
self.cursor_instances.deinit(alloc);
self.packed_instances.deinit(alloc);
self.* = .{};
}
fn invalidateAfterResize(self: *RenderCache) void {
for (self.rows) |*row| {
row.gpu_offset_instances = 0;
row.gpu_len_instances = 0;
}
self.cursor_instances.clearRetainingCapacity();
self.packed_instances.clearRetainingCapacity();
self.total_instance_count = 0;
self.layout_dirty = true;
}
fn rebuildCursorInstances(
self: *RenderCache,
alloc: std.mem.Allocator,
cursor_instances: []const renderer.Instance,
) !CursorRebuildResult {
const old_len = self.cursor_instances.items.len;
self.cursor_instances.clearRetainingCapacity();
try self.cursor_instances.appendSlice(alloc, cursor_instances);
self.packed_instances.clearRetainingCapacity();
self.total_instance_count = 0;
self.layout_dirty = true;
return .{
.len_changed = markLayoutDirtyOnLenChange(old_len, self.cursor_instances.items.len),
.packed_invalidated = true,
};
}
};
const RowPackResult = struct {
total_instances: u32,
cursor_offset_instances: u32,
cursor_len_instances: u32,
};
const RowRebuildResult = struct {
len_changed: bool,
};
const CursorRebuildResult = struct {
len_changed: bool,
packed_invalidated: bool,
};
const RenderUploadPlanInput = struct {
layout_dirty: bool,
rows_rebuilt: usize,
cursor_rebuilt: bool,
};
const RenderUploadPlanResult = struct {
full_upload: bool,
partial_upload: bool,
};
fn applyRenderPlan(input: RenderUploadPlanInput) RenderUploadPlanResult {
if (input.layout_dirty) {
return .{
.full_upload = true,
.partial_upload = false,
};
}
return .{
.full_upload = false,
.partial_upload = input.rows_rebuilt > 0 or input.cursor_rebuilt,
};
}
fn repackRowCaches(
alloc: std.mem.Allocator,
packed_instances: *std.ArrayListUnmanaged(renderer.Instance),
rows: []RowInstanceCache,
cursor_instances: []const renderer.Instance,
) !RowPackResult {
packed_instances.clearRetainingCapacity();
var total_instances: u32 = 0;
for (rows) |*row| {
try packed_instances.appendSlice(alloc, row.instances.items);
total_instances += @intCast(row.instances.items.len);
}
const cursor_offset_instances = total_instances;
try packed_instances.appendSlice(alloc, cursor_instances);
total_instances += @intCast(cursor_instances.len);
var offset: u32 = 0;
for (rows) |*row| {
row.gpu_offset_instances = offset;
row.gpu_len_instances = @intCast(row.instances.items.len);
offset += @intCast(row.instances.items.len);
}
return .{
.total_instances = total_instances,
.cursor_offset_instances = cursor_offset_instances,
.cursor_len_instances = @intCast(cursor_instances.len),
};
}
fn rebuildRowInstances(
alloc: std.mem.Allocator,
cache: *RowInstanceCache,
row_idx: u32,
row_cells: anytype,
term: *const vt.Terminal,
face: *font.Face,
atlas: *font.Atlas,
cell_w: u32,
cell_h: u32,
baseline: u32,
default_bg: [4]f32,
bg_uv: font.GlyphUV,
selection: ?SelectionSpan,
) !RowRebuildResult {
const old_len = cache.instances.items.len;
cache.instances.clearRetainingCapacity();
cache.gpu_offset_instances = 0;
cache.gpu_len_instances = 0;
const raw_cells = row_cells.items(.raw);
var col_idx: u32 = 0;
while (col_idx < raw_cells.len) : (col_idx += 1) {
const cp = raw_cells[col_idx].codepoint();
const base_colors = term.cellColors(row_cells.get(col_idx));
const is_selected = if (selection) |span| span.containsCell(col_idx, row_idx) else false;
const colors = selectionColors(base_colors, is_selected);
const glyph_uv = if (cp == 0 or cp == ' ')
null
else
atlas.getOrInsert(face, @intCast(cp)) catch null;
try appendCellInstances(
alloc,
&cache.instances,
row_idx,
col_idx,
cell_w,
cell_h,
baseline,
glyph_uv,
bg_uv,
colors,
default_bg,
);
}
return .{
.len_changed = markLayoutDirtyOnLenChange(old_len, cache.instances.items.len),
};
}
fn markLayoutDirtyOnLenChange(old_len: usize, new_len: usize) bool {
return old_len != new_len;
}
fn clearConsumedDirtyFlags(
render_dirty: *vt.RenderDirty,
dirty_rows: []bool,
plan: RowRefreshPlan,
) void {
if (plan.full_rebuild) {
@memset(dirty_rows, false);
render_dirty.* = .false;
return;
}
var row_idx: usize = 0;
while (row_idx < dirty_rows.len) : (row_idx += 1) {
if (plan.rows_to_rebuild.isSet(row_idx)) dirty_rows[row_idx] = false;
}
for (dirty_rows) |dirty| {
if (dirty) {
render_dirty.* = .partial;
return;
}
}
render_dirty.* = .false;
}
fn cursorOffsetInstances(rows: []const RowInstanceCache) u32 {
var offset: u32 = 0;
for (rows) |row| offset += row.gpu_len_instances;
return offset;
}
fn makeTestInstances(
alloc: std.mem.Allocator,
count: usize,
) !std.ArrayListUnmanaged(renderer.Instance) {
var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
try instances.ensureTotalCapacity(alloc, count);
var i: usize = 0;
while (i < count) : (i += 1) {
try instances.append(alloc, .{
.cell_pos = .{ @floatFromInt(i), 0.0 },
.glyph_size = .{ 1.0, 1.0 },
.glyph_bearing = .{ 0.0, 0.0 },
.uv_rect = .{ 0.0, 0.0, 1.0, 1.0 },
.fg = .{ 1.0, 1.0, 1.0, 1.0 },
.bg = .{ 0.0, 0.0, 0.0, 1.0 },
});
}
return instances;
}
fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
const window = try conn.createWindow(alloc, "waystty-text-compare");
defer window.deinit();
_ = conn.display.roundtrip();
var font_lookup = try font.lookupConfiguredFont(alloc);
defer font_lookup.deinit(alloc);
var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
defer face.deinit();
var atlas = try font.Atlas.init(alloc, 2048, 2048);
defer atlas.deinit();
var geom: ScaledGeometry = .{
.buffer_scale = 1,
.px_size = config.font_size_px,
.cell_w_px = face.cellWidth(),
.cell_h_px = face.cellHeight(),
.baseline_px = face.baseline(),
};
var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
defer scene.deinit(alloc);
// Initial surface-coordinate window size. Prefer whatever the compositor
// configured us at (already stored on window by xdgToplevelListener). If
// the initial configure was (0, 0) — meaning "client chooses" — use a
// size that fits the scene exactly at scale=1.
if (window.width == 800 and window.height == 600) {
window.width = scene.window_cols * geom.cell_w_px;
window.height = scene.window_rows * geom.cell_h_px;
}
var ctx = try renderer.Context.init(
alloc,
@ptrCast(conn.display),
@ptrCast(window.surface),
window.width * @as(u32, @intCast(geom.buffer_scale)),
window.height * @as(u32, @intCast(geom.buffer_scale)),
);
defer ctx.deinit();
try ctx.uploadAtlas(atlas.pixels);
atlas.dirty = false;
try ctx.uploadInstances(scene.instances.items);
const wl_fd = conn.display.getFd();
var pollfds = [_]std.posix.pollfd{
.{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
};
var last_window_w = window.width;
var last_window_h = window.height;
var last_scale: i32 = geom.buffer_scale;
while (!window.should_close) {
_ = conn.display.flush();
if (conn.display.prepareRead()) {
pollfds[0].revents = 0;
_ = std.posix.poll(&pollfds, 16) catch {};
if (pollfds[0].revents & std.posix.POLL.IN != 0) {
_ = conn.display.readEvents();
} else {
conn.display.cancelRead();
}
}
_ = conn.display.dispatchPending();
const current_scale = window.bufferScale();
const scale_changed = current_scale != last_scale;
const size_changed = window.width != last_window_w or window.height != last_window_h;
if (scale_changed or size_changed) {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
if (scale_changed) {
geom = try rebuildFaceForScale(
&face,
&atlas,
font_lookup.path,
font_lookup.index,
config.font_size_px,
current_scale,
);
// Rebuild the scene against the fresh atlas.
scene.deinit(alloc);
scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
// Do NOT touch window.width/window.height here — those reflect the
// compositor's configured surface size (from xdg_toplevel.configure).
// Overwriting them forced sway to non-integer-scale our buffer to fit
// its tile, which was the actual cause of the residual fuzz.
window.surface.setBufferScale(geom.buffer_scale);
try ctx.uploadAtlas(atlas.pixels);
atlas.dirty = false;
try ctx.uploadInstances(scene.instances.items);
last_scale = current_scale;
}
const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
try ctx.recreateSwapchain(buf_w, buf_h);
last_window_w = window.width;
last_window_h = window.height;
}
drawTextCoverageCompareFrame(
&ctx,
&scene,
geom.cell_w_px,
geom.cell_h_px,
.{ 0.0, 0.0, 0.0, 1.0 },
) catch |err| switch (err) {
error.OutOfDateKHR => {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
try ctx.recreateSwapchain(buf_w, buf_h);
last_window_w = window.width;
last_window_h = window.height;
continue;
},
else => return err,
};
_ = conn.display.flush();
std.Thread.sleep(16 * std.time.ns_per_ms);
}
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
}
fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
std.debug.print("wayland connected\n", .{});
const window = try conn.createWindow(alloc, "waystty-draw-smoke");
defer window.deinit();
std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
_ = conn.display.roundtrip();
var ctx = try renderer.Context.init(
alloc,
@ptrCast(conn.display),
@ptrCast(window.surface),
window.width,
window.height,
);
defer ctx.deinit();
std.debug.print("vulkan context created\n", .{});
// Load configured font and create atlas
var font_lookup = try font.lookupConfiguredFont(alloc);
defer font_lookup.deinit(alloc);
const px_size: u32 = config.font_size_px;
var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, px_size);
defer face.deinit();
var atlas = try font.Atlas.init(alloc, 1024, 1024);
defer atlas.deinit();
// Rasterize 'M' into the atlas
const glyph_uv = try atlas.getOrInsert(&face, 'M');
std.debug.print("glyph 'M': uv=({d:.3},{d:.3})->({d:.3},{d:.3}) size={d}x{d}\n", .{
glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1,
glyph_uv.width, glyph_uv.height,
});
// Upload atlas pixels to GPU
try ctx.uploadAtlas(atlas.pixels);
std.debug.print("atlas uploaded\n", .{});
// Cell size from font metrics
const cell_w: f32 = @floatFromInt(face.cellWidth());
const cell_h: f32 = @floatFromInt(face.cellHeight());
const baseline = face.baseline();
std.debug.print("cell size: {d}x{d}\n", .{ cell_w, cell_h });
// One Instance: 'M' at cell position (40, 12) — near center of 80x24 grid
const instances = [_]renderer.Instance{
.{
.cell_pos = .{ 40.0, 12.0 },
.glyph_size = .{ @floatFromInt(glyph_uv.width), @floatFromInt(glyph_uv.height) },
.glyph_bearing = .{
@floatFromInt(glyph_uv.bearing_x),
glyphTopOffset(baseline, glyph_uv.bearing_y),
},
.uv_rect = .{ glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1 },
.fg = .{ 1.0, 1.0, 1.0, 1.0 },
.bg = .{ 0.0, 0.0, 0.0, 1.0 },
},
};
try ctx.uploadInstances(&instances);
std.debug.print("instances uploaded, rendering for ~15 seconds at 60fps...\n", .{});
var i: u32 = 0;
while (i < 900) : (i += 1) {
// Non-blocking Wayland event read: prepare + read + dispatch.
// The Vulkan WSI (FIFO) needs wl_buffer.release events from the compositor
// to be read off the socket before it can release an acquire slot.
_ = conn.display.flush();
if (conn.display.prepareRead()) {
_ = conn.display.readEvents();
}
_ = conn.display.dispatchPending();
const baseline_coverage = renderer.coverageVariantParams(.baseline);
ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage) catch |err| switch (err) {
error.OutOfDateKHR => {
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
try ctx.recreateSwapchain(window.width, window.height);
continue;
},
else => return err,
};
_ = conn.display.flush();
std.Thread.sleep(16 * std.time.ns_per_ms);
}
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
std.debug.print("done\n", .{});
}
test "encodeKeyboardEvent encodes left arrow" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
var buf: [32]u8 = undefined;
const encoded = (try encodeKeyboardEvent(term, .{
.keysym = c.XKB_KEY_Left,
.modifiers = .{},
.action = .press,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}, &buf)).?;
try std.testing.expectEqualStrings("\x1b[D", encoded);
}
test "gridSizeForWindow clamps to at least one cell" {
const grid = gridSizeForWindow(10, 5, 16, 20);
try std.testing.expectEqual(@as(u16, 1), grid.cols);
try std.testing.expectEqual(@as(u16, 1), grid.rows);
}
test "isClipboardPasteEvent matches Ctrl-Shift-V press" {
try std.testing.expect(isClipboardPasteEvent(.{
.keysym = c.XKB_KEY_V,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .press,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
try std.testing.expect(!isClipboardPasteEvent(.{
.keysym = c.XKB_KEY_V,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .repeat,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
try std.testing.expect(!isClipboardPasteEvent(.{
.keysym = c.XKB_KEY_v,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .press,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
}
test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
try std.testing.expect(isClipboardCopyEvent(.{
.keysym = c.XKB_KEY_C,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .press,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
try std.testing.expect(!isClipboardCopyEvent(.{
.keysym = c.XKB_KEY_C,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .repeat,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
try std.testing.expect(!isClipboardCopyEvent(.{
.keysym = c.XKB_KEY_c,
.modifiers = .{ .ctrl = true, .shift = true },
.action = .press,
.utf8 = [_]u8{0} ** 8,
.utf8_len = 0,
}));
}
test "copySelectionText returns false for empty visible selection" {
var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 4, .rows = 1 });
defer term.deinit();
try std.testing.expect(!try copySelectionText(
std.testing.allocator,
null,
term,
null,
0,
));
}
test "extractSelectedText trims trailing blanks on each visible row" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 8,
.rows = 2,
});
defer term.deinit();
term.write("ab\r\nc");
try term.snapshot();
const span = SelectionSpan{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 7, .row = 1 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("ab\nc", text);
}
test "extractSelectedText preserves interior spaces while trimming trailing blanks" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 10,
.rows = 1,
});
defer term.deinit();
term.write("a b c ");
try term.snapshot();
const span = SelectionSpan{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 9, .row = 0 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("a b c ", text);
}
test "extractSelectedText emits a wide glyph once when its spacer cell is selected too" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 4,
.rows = 1,
});
defer term.deinit();
term.write("表");
try term.snapshot();
const row_cells = term.render_state.row_data.items(.cells)[0];
try std.testing.expectEqual(.wide, row_cells.get(0).raw.wide);
try std.testing.expectEqual(.spacer_tail, row_cells.get(1).raw.wide);
const span = SelectionSpan{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 1, .row = 0 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("表", text);
}
test "extractSelectedText respects partial first and last rows" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 8,
.rows = 2,
});
defer term.deinit();
term.write("abc\r\ndef");
try term.snapshot();
const span = SelectionSpan{
.start = .{ .col = 1, .row = 0 },
.end = .{ .col = 1, .row = 1 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("bc\nde", text);
}
test "extractSelectedText preserves grapheme clusters from render state" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 4,
.rows = 1,
});
defer term.deinit();
term.write("e\xcc\x81");
try term.snapshot();
const row_cells = term.render_state.row_data.items(.cells)[0];
const cell = row_cells.get(0);
try std.testing.expect(cell.raw.hasGrapheme());
try std.testing.expectEqualSlices(u21, &.{0x0301}, cell.grapheme);
const span = SelectionSpan{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 0, .row = 0 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("e\xcc\x81", text);
}
test "extractSelectedText clamps an offscreen end row to visible rows" {
var term = try vt.Terminal.init(std.testing.allocator, .{
.cols = 8,
.rows = 2,
});
defer term.deinit();
term.write("row0\r\nrow1");
try term.snapshot();
const span = SelectionSpan{
.start = .{ .col = 0, .row = 0 },
.end = .{ .col = 7, .row = 9 },
};
const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
defer std.testing.allocator.free(text);
try std.testing.expectEqualStrings("row0\nrow1", text);
}
test "drainSelectionPipeThenRoundtrip drains large paste before roundtrip" {
const payload_len: usize = 8192;
const payload = try std.testing.allocator.alloc(u8, payload_len);
defer std.testing.allocator.free(payload);
@memset(payload, 'p');
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
var write_fd_closed = false;
defer if (!write_fd_closed) std.posix.close(pipefds[1]);
var written: usize = 0;
while (written < payload.len) {
written += try std.posix.write(pipefds[1], payload[written..]);
}
std.posix.close(pipefds[1]);
write_fd_closed = true;
var roundtrip_ctx = struct {
fd: std.posix.fd_t,
called: bool = false,
pub fn roundtrip(self: *@This()) !void {
var probe: [1]u8 = undefined;
const n = try std.posix.read(self.fd, &probe);
try std.testing.expectEqual(@as(usize, 0), n);
self.called = true;
}
}{ .fd = pipefds[0] };
const text = try wayland_client.drainSelectionPipeThenRoundtrip(
std.testing.allocator,
pipefds[0],
&roundtrip_ctx,
);
defer std.testing.allocator.free(text);
try std.testing.expectEqualSlices(u8, payload, text);
try std.testing.expect(roundtrip_ctx.called);
}
test "appendCellInstances emits a background quad for colored space" {
var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
defer instances.deinit(std.testing.allocator);
const bg_uv: font.GlyphUV = .{
.u0 = 0.0,
.v0 = 0.0,
.u1 = 0.1,
.v1 = 0.1,
.width = 1,
.height = 1,
.bearing_x = 0,
.bearing_y = 0,
.advance_x = 1,
};
try appendCellInstances(
std.testing.allocator,
&instances,
2,
3,
8,
16,
12,
null,
bg_uv,
.{
.fg = .{ 1.0, 1.0, 1.0, 1.0 },
.bg = .{ 0.2, 0.3, 0.4, 1.0 },
},
.{ 0.0, 0.0, 0.0, 1.0 },
);
try std.testing.expectEqual(@as(usize, 1), instances.items.len);
try std.testing.expectEqualDeep([4]f32{ 0.2, 0.3, 0.4, 1.0 }, instances.items[0].fg);
try std.testing.expectEqualDeep([4]f32{ 0.2, 0.3, 0.4, 1.0 }, instances.items[0].bg);
try std.testing.expectEqualDeep([2]f32{ 8.0, 16.0 }, instances.items[0].glyph_size);
}
test "appendCellInstances emits background before glyph" {
var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
defer instances.deinit(std.testing.allocator);
const bg_uv: font.GlyphUV = .{
.u0 = 0.0,
.v0 = 0.0,
.u1 = 0.1,
.v1 = 0.1,
.width = 1,
.height = 1,
.bearing_x = 0,
.bearing_y = 0,
.advance_x = 1,
};
const glyph_uv: font.GlyphUV = .{
.u0 = 0.2,
.v0 = 0.3,
.u1 = 0.4,
.v1 = 0.5,
.width = 7,
.height = 11,
.bearing_x = 1,
.bearing_y = 13,
.advance_x = 8,
};
try appendCellInstances(
std.testing.allocator,
&instances,
0,
1,
8,
16,
12,
glyph_uv,
bg_uv,
.{
.fg = .{ 0.9, 0.8, 0.7, 1.0 },
.bg = .{ 0.1, 0.2, 0.3, 1.0 },
},
.{ 0.0, 0.0, 0.0, 1.0 },
);
try std.testing.expectEqual(@as(usize, 2), instances.items.len);
try std.testing.expectEqualDeep([2]f32{ 8.0, 16.0 }, instances.items[0].glyph_size);
try std.testing.expectEqualDeep([2]f32{ 7.0, 11.0 }, instances.items[1].glyph_size);
try std.testing.expectEqualDeep([4]f32{ 0.1, 0.2, 0.3, 1.0 }, instances.items[0].fg);
try std.testing.expectEqualDeep([4]f32{ 0.9, 0.8, 0.7, 1.0 }, instances.items[1].fg);
try std.testing.expectEqualDeep([2]f32{ 1.0, -1.0 }, instances.items[1].glyph_bearing);
}
test "glyphTopOffset uses baseline rather than cell height" {
try std.testing.expectEqual(@as(f32, -3.0), glyphTopOffset(9, 12));
}
test "comparisonPanelOrigins splits four panels left to right" {
const origins = comparisonPanelOrigins(80, 24);
try std.testing.expectEqual(@as(f32, 0), origins[0][0]);
try std.testing.expect(origins[1][0] > origins[0][0]);
try std.testing.expect(origins[2][0] > origins[1][0]);
try std.testing.expect(origins[3][0] > origins[2][0]);
}
test "comparisonSpecimenLines remains fixed and non-empty" {
const lines = comparisonSpecimenLines();
try std.testing.expectEqual(@as(usize, 5), lines.len);
try std.testing.expectEqualStrings("abcdefghijklmnopqrstuvwxyz", lines[0]);
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWXYZ", lines[1]);
try std.testing.expectEqualStrings("0123456789", lines[2]);
try std.testing.expectEqualStrings("{}[]()/\\|,.;:_-=+", lines[3]);
try std.testing.expectEqualStrings("~/code/rad/waystty $ zig build test", lines[4]);
for (lines) |line| {
try std.testing.expect(line.len > 0);
}
}
test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
var lookup = try font.lookupConfiguredFont(std.testing.allocator);
defer lookup.deinit(std.testing.allocator);
var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
defer face.deinit();
var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
defer atlas.deinit();
var scene = try buildTextCoverageCompareScene(std.testing.allocator, &face, &atlas);
defer scene.deinit(std.testing.allocator);
const variants = comparisonVariants();
const first_draw = scene.panel_draws[0];
try std.testing.expect(first_draw.instance_count > 0);
var idx: usize = 0;
while (idx < scene.panel_draws.len) : (idx += 1) {
try std.testing.expect(scene.panel_draws[idx].instance_count >= first_draw.instance_count - 8);
try std.testing.expectEqualDeep(variants[idx].coverage, scene.panel_draws[idx].coverage);
}
const panel0_first = scene.instances.items[first_draw.instance_offset_instances];
const panel1_first = scene.instances.items[scene.panel_draws[1].instance_offset_instances];
try std.testing.expectEqual(panel0_first.cell_pos[1], panel1_first.cell_pos[1]);
try std.testing.expectEqual(
@as(f32, @floatFromInt(scene.panel_cols)),
panel1_first.cell_pos[0] - panel0_first.cell_pos[0],
);
}
test "FrameTiming.total sums all sections" {
const ft: FrameTiming = .{
.snapshot_us = 10,
.row_rebuild_us = 20,
.atlas_upload_us = 30,
.instance_upload_us = 40,
.gpu_submit_us = 50,
};
try std.testing.expectEqual(@as(u32, 150), ft.total());
}
test "FrameTimingRing records and wraps correctly" {
var ring = FrameTimingRing{};
try std.testing.expectEqual(@as(usize, 0), ring.count);
ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
try std.testing.expectEqual(@as(usize, 1), ring.count);
try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
// Fill to capacity
for (1..FrameTimingRing.capacity) |i| {
ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
}
try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
// One more wraps around — overwrites entries[0], head advances to 1
ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
// Newest entry is at (head + capacity - 1) % capacity = 0
try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
// head has advanced past the overwritten slot
try std.testing.expectEqual(@as(usize, 1), ring.head);
}
test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
var ring = FrameTimingRing{};
// Push capacity + 3 entries so the ring wraps
for (0..FrameTimingRing.capacity + 3) |i| {
ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
}
var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
const ordered = ring.orderedSlice(&buf);
try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
// First entry should be the 4th pushed (index 3), last should be capacity+2
try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
}
test "FrameTimingStats computes min/avg/p99/max correctly" {
var ring = FrameTimingRing{};
// Push 100 frames with snapshot_us = 1..100
for (0..100) |i| {
ring.push(.{
.snapshot_us = @intCast(i + 1),
.row_rebuild_us = 0,
.atlas_upload_us = 0,
.instance_upload_us = 0,
.gpu_submit_us = 0,
});
}
const stats = computeFrameStats(&ring);
try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
// p99 of 1..100 = value at index 98 (0-based) = 99
try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
}
test "FrameTimingStats handles empty ring" {
var ring = FrameTimingRing{};
const stats = computeFrameStats(&ring);
try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
}
fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
std.debug.print("wayland connected\n", .{});
const window = try conn.createWindow(alloc, "waystty-render-smoke");
defer window.deinit();
std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
_ = conn.display.roundtrip();
var ctx = try renderer.Context.init(
alloc,
@ptrCast(conn.display),
@ptrCast(window.surface),
window.width,
window.height,
);
defer ctx.deinit();
std.debug.print("rendering 60 frames...\n", .{});
var i: u32 = 0;
while (i < 60) : (i += 1) {
_ = conn.display.dispatchPending();
const t: f32 = @as(f32, @floatFromInt(i)) / 60.0;
try ctx.drawClear(.{ t, 0.5, 1.0 - t, 1.0 });
}
_ = try ctx.vkd.deviceWaitIdle(ctx.device);
std.debug.print("done\n", .{});
}
fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
std.debug.print("wayland connected\n", .{});
const window = try conn.createWindow(alloc, "waystty-vulkan-smoke");
defer window.deinit();
std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
// Roundtrip to ensure configure events have arrived before Vulkan touches the surface
_ = conn.display.roundtrip();
var ctx = try renderer.Context.init(
alloc,
@ptrCast(conn.display),
@ptrCast(window.surface),
window.width,
window.height,
);
defer ctx.deinit();
std.debug.print("vulkan ok\n", .{});
std.debug.print(" format: {any}\n", .{ctx.swapchain_format});
std.debug.print(" extent: {d}x{d}\n", .{ ctx.swapchain_extent.width, ctx.swapchain_extent.height });
std.debug.print(" image count: {d}\n", .{ctx.swapchain_images.len});
}
fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
const conn = try wayland_client.Connection.init(alloc);
defer conn.deinit();
std.debug.print("connected\n", .{});
const window = try conn.createWindow(alloc, "waystty");
defer window.deinit();
std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
}
fn runHeadless(alloc: std.mem.Allocator) !void {
const shell: [:0]const u8 = "/bin/sh";
var p = try pty.Pty.spawn(.{
.cols = 80,
.rows = 24,
.shell = shell,
});
defer p.deinit();
var term = try vt.Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
// Give shell a moment to start up
std.Thread.sleep(100 * std.time.ns_per_ms);
// Write a command that prints something then exits
_ = try p.write("echo hello; exit\n");
// Drain output for up to 2 seconds, or until child exits
var buf: [4096]u8 = undefined;
const deadline = std.time.nanoTimestamp() + 2 * std.time.ns_per_s;
while (std.time.nanoTimestamp() < deadline) {
const n = p.read(&buf) catch |err| switch (err) {
error.WouldBlock => {
if (!p.isChildAlive()) break;
std.Thread.sleep(10 * std.time.ns_per_ms);
continue;
},
// EIO typically means the slave side of the PTY was closed (child exited)
error.InputOutput => break,
else => return err,
};
if (n == 0) break;
term.write(buf[0..n]);
}
// Drain any remaining data after child exits
drain: while (true) {
const n = p.read(&buf) catch break :drain;
if (n == 0) break;
term.write(buf[0..n]);
}
try term.snapshot();
// Dump the grid to stdout
var out_buf: [65536]u8 = undefined;
var fw = std.fs.File.stdout().writer(&out_buf);
const w = &fw.interface;
const rows = term.render_state.row_data.items(.cells);
for (rows) |*row_cells| {
// Each row is a MultiArrayList(RenderState.Cell)
// RenderState.Cell has a .raw field of type page.Cell
// page.Cell has a .codepoint() method returning u21
const raw_cells = row_cells.items(.raw);
for (raw_cells) |cell| {
const cp = cell.codepoint();
if (cp == 0) {
// Empty cell — print a space
try w.writeByte(' ');
} else {
var utf8_buf: [4]u8 = undefined;
const utf8_len = std.unicode.utf8Encode(cp, &utf8_buf) catch {
try w.writeByte('?');
continue;
};
try w.writeAll(utf8_buf[0..utf8_len]);
}
}
try w.writeByte('\n');
}
try w.flush();
}