const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("../pardes.zig"); const dump = @import("../dump.zig"); const config = @import("../config.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const deck = @import("deck.zig"); const Post = @import("Post.zig"); const pet = @import("pet.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); const c_heap = @import("c_heap"); const ninep_io = @import("../9p_io.zig"); const detached_client = @import("../detached/client.zig"); const tracy = @import("../tracy.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); @cInclude("font.h"); }); const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = @embedFile("ui.vert.spv"); const frag_spv = @embedFile("ui.frag.spv"); const decor_frag_spv = @embedFile("decor.frag.spv"); const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); const image_frag_spv = @embedFile("image.frag.spv"); const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; const Ground = struct { rgb: [3]u8, clear: bool, fn opaqueRgb(rgb: [3]u8) Ground { return .{ .rgb = rgb, .clear = false }; } }; fn ground(theme_bg: ?[3]u8, transparent: bool) Ground { if (theme_bg) |rgb| return .opaqueRgb(rgb); return .{ .rgb = bg_default, .clear = transparent }; } const p9_arrow_clr = [32]u8{ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, }; const p9_arrow_set = [32]u8{ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; /// acme's boxcursor (acme.c:956), while a grip carries a pane or column. const p9_box_clr = [32]u8{ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, }; const p9_box_set = [32]u8{ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00, }; const p9_box_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl; break :blk m; }; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; break :blk m; }; const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; /// An atlas entry is a glyph of a face (0 the primary font, i the fallback /// i - 1) at a role's size, drawn `span` cells wide with its own cell `lead` /// cells in: a ligature is one strip whose cells sample their own slices. /// Packed, so the atlas map hashes one word instead of walking fields. const GlyphKey = packed struct(u64) { face: u8, glyph: u32, role: pardes.FontRole, decoration: GlyphDecoration = .{}, centered: bool = false, lead: u4 = 0, span: u4 = 1, _: u2 = 0, }; const GlyphDecoration = packed struct(u5) { bold: bool = false, underline: @FieldType(pardes.CellStyle, "ul") = .off, strikethrough: bool = false, fn fromCell(cell: *const pardes.Cell) GlyphDecoration { if (cell.default or cell.style.invisible) return .{}; return .{ .bold = cell.style.bold, .underline = cell.style.ul, .strikethrough = cell.style.strikethrough }; } fn drawsBlank(decoration: GlyphDecoration) bool { return decoration.underline != .off or decoration.strikethrough; } }; const TaglineRaster = struct { px: f32, width: u32, baseline: i32, height: u32, }; fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u32, percent: u8) TaglineRaster { const px = body_px * @as(f32, @floatFromInt(percent)) / 100.0; var own_w: c_int = 1; var own_h: c_int = 1; var own_ascent: c_int = 1; c.ui_font_cell_metrics(font, px, &own_w, &own_h, &own_ascent); const fixed_w: c_int = @intCast(body_cell_w); const fixed_h: c_int = @intCast(body_cell_h); const band_h = std.math.clamp(own_h, 1, fixed_h); return .{ .px = px, .width = @intCast(std.math.clamp(own_w, 1, fixed_w)), .baseline = std.math.clamp(own_ascent, 1, band_h), .height = @intCast(band_h), }; } /// What FreeType and HarfBuzz allocate from, through ui_malloc and friends: /// the shared C heap, exported under the names font.c and HarfBuzz's /// hb_malloc_impl expect. runNative points it at the gui's gpa once /// HarfBuzz's process-wide objects exist (ui_font_prime), so a Debug build's /// leak report is about fonts; a test that counts what fonts leak points it /// at std.testing.allocator the same way. Only the render thread calls into /// fonts, and every allocator it is pointed at is thread-safe anyway. var font_allocator: std.mem.Allocator = std.heap.smp_allocator; const font_heap = c_heap.Heap(&font_allocator); comptime { @export(&font_heap.malloc, .{ .name = "ui_malloc" }); @export(&font_heap.calloc, .{ .name = "ui_calloc" }); @export(&font_heap.realloc, .{ .name = "ui_realloc" }); @export(&font_heap.free, .{ .name = "ui_free" }); } test "FreeType and HarfBuzz allocate from the gui allocator and give it all back" { // HarfBuzz's process-wide objects come from the default heap, as in runNative. const primer = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; c.ui_font_prime(primer); c.ui_font_free(primer); font_allocator = std.testing.allocator; defer font_allocator = std.heap.smp_allocator; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var mask: [64 * 64]u8 = undefined; try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, 27, c.ui_font_glyph(font, 'g'), 1, &mask, 64, 32, 48, 0, 32, 30)); try std.testing.expectEqual(@as(c_int, 0), c.ui_font_substitutes(font, c.ui_font_glyph(font, '-'))); // A font with ligatures runs the whole HarfBuzz pass. const bytes = try filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf"); defer std.testing.allocator.free(bytes); const maple = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(maple); var cell_w: c_int = 0; var cell_h: c_int = 0; var ascent: c_int = 0; c.ui_font_cell_metrics(maple, 27, &cell_w, &cell_h, &ascent); const text = [_]u32{ 'a', '-', '>', 'b', '=', '=' }; var shaped: [text.len]c.UIShapedCell = undefined; c.ui_font_shape(maple, 27, cell_w, &text, text.len, &shaped); try std.testing.expectEqual(@as(u8, 2), shaped[1].span); try std.testing.expectEqual(@as(u8, 2), shaped[4].span); } /// What SDL allocates from, on its own threads as well as the render thread: /// the gui's gpa, through the shared C heap, which is thread-safe in every /// build. runNative hands everything back and calls SDL_Quit on the way out. var sdl_allocator: std.mem.Allocator = undefined; const sdl_heap = c_heap.Heap(&sdl_allocator); const max_fallback_fonts = 1 + fonts.fallback_names.len; const LoadedFallback = struct { face: *c.UIFont, bytes: []u8 = &.{}, }; const max_touch_points: usize = 16; const max_touch_trail_points: usize = 8; const overlay_circle_vertices: usize = 16 * 3; const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; test "a new panel's frame zero is presented before it advances" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const frame = pardes.animation.frame_ns; core.advance(100); const idle = try core.render(arena.allocator()); core.acknowledgePanelPresentation(idle.panelTracks()); try std.testing.expectEqual(@as(?u64, null), core.nextWake()); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); _ = arena.reset(.retain_capacity); const first = try core.render(arena.allocator()); const track_count = first.panelTracks().len; try std.testing.expect(track_count > 0); for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); core.acknowledgePanelPresentation(first.panelTracks()); // Just short of a frame is no frame at all. core.advance(100 + frame - 1); _ = arena.reset(.retain_capacity); for ((try core.render(arena.allocator())).panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); core.advance(100 + frame); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); try std.testing.expectEqual(track_count, second.panelTracks().len); for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } // The triangle overlay holds only the pet and the touch view: the chrome is // decor. const max_overlay_vertices: usize = pet.max_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; const font_px_max: f32 = 72.0; test "every font size step moves the cell, and the ends are reachable exactly" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var px = font_px_min + font_px_step; while (px <= font_px_max) : (px += 1.0) { var cw: c_int = 0; var ch: c_int = 0; var asc: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; c.ui_font_cell_metrics(font, px, &cw, &ch, &asc); c.ui_font_cell_metrics(font, px - font_px_step, &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max)); } test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); const h = c.ui_font_glyph(font, 'H'); try std.testing.expect(h != 0); try std.testing.expectEqual(@as(u32, 0), c.ui_font_glyph(font, 0x10ffff)); const size: f32 = 13.0; var cell_w: c_int = 0; var cell_h: c_int = 0; var ascent: c_int = 0; c.ui_font_cell_metrics(font, size, &cell_w, &cell_h, &ascent); try std.testing.expect(cell_w > 0 and cell_w <= 128); try std.testing.expect(cell_h > 0 and cell_h <= 128); const stride: c_int = 128; var bitmap: [128 * 128]u8 = @splat(0xaa); try std.testing.expectEqual( @as(c_int, 1), c.ui_font_raster(font, size, h, 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); var ink: usize = 0; var soft: usize = 0; var regular_coverage: usize = 0; for (0..@intCast(cell_h)) |y| { for (0..@intCast(cell_w)) |x| { const coverage = bitmap[y * @as(usize, @intCast(stride)) + x]; if (coverage > 0) ink += 1; if (coverage > 0 and coverage < 255) soft += 1; regular_coverage += coverage; } } try std.testing.expect(ink > 0); try std.testing.expect(soft > 0); // Synthetic bold thickens the outline inside the same cell. try std.testing.expectEqual( @as(c_int, 1), c.ui_font_raster(font, size, h, 1, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); var bold_coverage: usize = 0; for (0..@intCast(cell_h)) |y| { for (0..@intCast(cell_w)) |x| bold_coverage += bitmap[y * @as(usize, @intCast(stride)) + x]; for (bitmap[y * @as(usize, @intCast(stride)) + @as(usize, @intCast(cell_w)) ..][0..@intCast(stride - cell_w)]) |guard| try std.testing.expectEqual(@as(u8, 0xaa), guard); } try std.testing.expect(bold_coverage > regular_coverage); try std.testing.expectEqual( @as(c_int, 0), c.ui_font_raster(font, size, c.ui_font_glyph(font, ' '), 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); for (0..@intCast(cell_h)) |y| for (0..@intCast(cell_w)) |x| try std.testing.expectEqual( @as(u8, 0), bitmap[y * @as(usize, @intCast(stride)) + x], ); } const InkBounds = struct { min_x: usize, min_y: usize, max_x: usize, max_y: usize }; fn inkBounds(bitmap: []const u8, stride: usize, w: usize, h: usize) ?InkBounds { var bounds: ?InkBounds = null; for (0..h) |y| for (0..w) |x| { if (bitmap[y * stride + x] == 0) continue; if (bounds) |*b| { b.min_x = @min(b.min_x, x); b.min_y = @min(b.min_y, y); b.max_x = @max(b.max_x, x); b.max_y = @max(b.max_y, y); } else bounds = .{ .min_x = x, .min_y = y, .max_x = x, .max_y = y }; }; return bounds; } test "tagline glyph shrinks into its own cell and stays vertically centered" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); const body_px: f32 = 27.0; var cell_w: c_int = 1; var cell_h: c_int = 1; var body_ascent: c_int = 1; c.ui_font_cell_metrics(font, body_px, &cell_w, &cell_h, &body_ascent); const tag = taglineRaster(font, body_px, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent); try std.testing.expect(tag.px < body_px); try std.testing.expect(tag.width < @as(u32, @intCast(cell_w))); const stride: c_int = 128; var body: [128 * 128]u8 = @splat(0); var tagline: [128 * 128]u8 = @splat(0); const h = c.ui_font_glyph(font, 'H'); try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, body_px, h, 0, &body, stride, cell_w, cell_h, 0, cell_w, body_ascent)); try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, tag.px, h, 0, &tagline, stride, @intCast(tag.width), @intCast(tag.height), 0, @intCast(tag.width), tag.baseline)); const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?; const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?; try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x); try std.testing.expect(t.max_y - t.min_y < b.max_y - b.min_y); const body_center_x: isize = @intCast(b.min_x + b.max_x); const tag_center_x: isize = @intCast(t.min_x + t.max_x); const body_center_y: isize = @intCast(b.min_y + b.max_y); const centered_top: isize = @intCast((@as(u32, @intCast(cell_h)) - tag.height) / 2); const tag_center_y: isize = @as(isize, @intCast(t.min_y + t.max_y)) + centered_top * 2; const body_slot_center: isize = @intCast(@as(u32, @intCast(cell_w)) - 1); const tag_slot_center: isize = @intCast(tag.width - 1); try std.testing.expect(@abs((tag_center_x - tag_slot_center) - (body_center_x - body_slot_center)) <= 2); try std.testing.expect(@abs(tag_center_y - body_center_y) <= 4); try std.testing.expect(tag.height < @as(u32, @intCast(cell_h))); } test "tagline percentage changes measured band height without body metrics" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var cell_w: c_int = 1; var cell_h: c_int = 1; var ascent: c_int = 1; c.ui_font_cell_metrics(font, 27.0, &cell_w, &cell_h, &ascent); const small = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 40); const configured = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 82); const full = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 100); try std.testing.expect(small.width < configured.width); try std.testing.expect(configured.width <= full.width); try std.testing.expectEqual(@as(u32, @intCast(cell_w)), full.width); try std.testing.expect(small.height < configured.height); try std.testing.expect(configured.height <= full.height); try std.testing.expect(full.height <= @as(u32, @intCast(cell_h))); } test "glyph atlas cache separates body and tagline roles" { var glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer glyphs.deinit(); try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body }, .{ .u = 0, .v = 0 }); try glyphs.put(.{ .face = 0, .glyph = 36, .role = .tagline }, .{ .u = 10, .v = 0 }); try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 }); try std.testing.expectEqual(@as(usize, 3), glyphs.count()); try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .face = 0, .glyph = 36, .role = .body }).?.u); try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .face = 0, .glyph = 36, .role = .tagline }).?.u); } test "installed Nerd Symbols fallback covers Yazi directory icons" { try std.testing.expectEqualStrings("SymbolsNerdFont-Regular", fonts.fallback_names[2]); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const candidate = for (fonts.fallbacks(arena_state.allocator())) |font| { if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font; } else return; const bytes = try filesystem.readFile(std.testing.allocator, candidate.path); defer std.testing.allocator.free(bytes); const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(face); try std.testing.expect(c.ui_font_glyph(face, 0xe5ff) != 0); } const CellInstance = extern struct { x0: f32, y0: f32, x1: f32, y1: f32, u0: f32, v0: f32, u1: f32, v1: f32, fr: f32, fg: f32, fb: f32, br: f32, bg: f32, bb: f32, panel_x0: f32 = 0, panel_y0: f32 = 0, panel_x1: f32 = 0, panel_y1: f32 = 0, present_x0: f32 = 0, present_y0: f32 = 0, present_x1: f32 = 0, present_y1: f32 = 0, effect: u32 = 0, progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, /// A clip rectangle in target pixels; x1 < x0 is none (ui.vert.glsl). clip_x0: f32 = 0, clip_y0: f32 = 0, clip_x1: f32 = -1, clip_y1: f32 = -1, }; const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32, // CPU draw grouping metadata; not a shader attribute. ink: u32 = 0, }; const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; const ImageInstance = extern struct { x0: f32, y0: f32, x1: f32, y1: f32, u0: f32, v0: f32, u1: f32, v1: f32, panel_x0: f32 = 0, panel_y0: f32 = 0, panel_x1: f32 = 0, panel_y1: f32 = 0, present_x0: f32 = 0, present_y0: f32 = 0, present_x1: f32 = 0, present_y1: f32 = 0, effect: u32 = 0, progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, }; const initial_image_capacity: u32 = pardes.MAX_PANES; const old_layer_bit: u32 = 0x8000_0000; const clear_bg_bit: u32 = 0x4000_0000; const opaque_bg_bit: u32 = 0x2000_0000; /// Decor that is a soft shadow, not a flat fill (decor.frag.glsl). const soft_shadow_bit: u32 = 0x1000_0000; /// Decor that is a checker (decor.frag.glsl). const checker_bit: u32 = 0x0800_0000; /// A cell on the page's ground under the theme's dots, and a file name's /// cell that casts the theme's shadow (ui.frag.glsl, Chrome.decor_*). const dotted_bit: u32 = 0x1000_0000; const title_bit: u32 = 0x0800_0000; const SavedImagePlace = struct { key: pardes.ImageCacheKey, pane: u8, serial: u32, native: pardes.NativePlacement, x: u16, y: u16, w: u16, h: u16, iw: usize, ih: usize, fn from(place: pardes.ImagePlace) SavedImagePlace { return .{ .key = place.cacheKey(), .pane = place.pane, .serial = place.serial, .native = place.native, .x = place.x, .y = place.y, .w = place.w, .h = place.h, .iw = place.iw, .ih = place.ih, }; } fn cacheKey(place: SavedImagePlace) pardes.ImageCacheKey { return place.key; } }; const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, track: ?pardes.animation.Track = null, clip: ?pardes.animation.Box = null, old_layer: bool = false, }; test "saved native placement retains identity and geometry but no producer bytes" { try std.testing.expect(!@hasField(SavedImagePlace, "rgba")); const current: pardes.ImagePlace = .{ .pane = 3, .serial = 91, .x = 4, .y = 5, .w = 6, .h = 7, .rgba = &.{ 1, 2, 3, 4 }, .iw = 1, .ih = 1, }; const saved = SavedImagePlace.from(current); try std.testing.expect(sameSavedPlacement(saved, current)); try std.testing.expect(placeIntersectsBox(saved, .{ .x = 8, .y = 8, .w = 4, .h = 4 })); try std.testing.expect(!placeIntersectsBox(saved, .{ .x = 20, .y = 20, .w = 2, .h = 2 })); } test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 120), @sizeOf(CellInstance)); try std.testing.expectEqual(@as(usize, 104), @offsetOf(CellInstance, "clip_x0")); try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect")); try std.testing.expectEqual(@as(usize, 80), @sizeOf(ImageInstance)); try std.testing.expectEqual(@as(usize, 32), @offsetOf(ImageInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 48), @offsetOf(ImageInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect")); try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32)); const tracks = [_]pardes.animation.Track{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; // Tier 0, the tracks as the core ordered them, then tiers 4 and 5. const groups = makeGroups(&tracks, true); try std.testing.expectEqual(@as(usize, 7), groups.len); try std.testing.expect(groups.items[0].track == null); try std.testing.expectEqual(@as(u8, 0), groups.items[1].track.?.pane); try std.testing.expectEqual(@as(u8, 1), groups.items[2].track.?.pane); try std.testing.expectEqual(@as(u8, 2), groups.items[3].track.?.pane); try std.testing.expectEqual(@as(usize, 4), groups.notices); try std.testing.expectEqual(@as(usize, 5), groups.guides); try std.testing.expectEqual(@as(usize, 6), groups.ink); for ([_]usize{ groups.notices, groups.guides, groups.ink }) |index| try std.testing.expect(groups.items[index].track == null); } test "GUI paint plan snaps history effects without a frozen grid" { const tracks = [_]pardes.animation.Track{ .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; try std.testing.expectEqual(@as(usize, 0), makeGroups(&tracks, false).tracks); const ready = makeGroups(&tracks, true); try std.testing.expectEqual(@as(usize, 2), ready.tracks); try std.testing.expectEqual(pardes.animation.Phase.opening, ready.items[1].track.?.phase); try std.testing.expectEqual(pardes.animation.Phase.closing, ready.items[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { const closing: pardes.animation.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; const groups = makeGroups(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); // A still pane's notice stays under the tombstone sliding over it. const notice: pardes.Layer = .{ .kind = .notice, .viewport = .{ .x = 4, .y = 4, .w = 3, .h = 1 }, .rows = 1 }; try std.testing.expectEqual(@as(usize, 0), noticeGroup(&groups, ¬ice)); try std.testing.expectEqual(makeGroups(&.{}, false).notices, noticeGroup(&makeGroups(&.{}, false), ¬ice)); try std.testing.expectEqual(@as(usize, 1), groups.tracks); try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { const metrics: pardes.RowMetrics = .{ .body_w = @intCast(cell_w), .body_h = @intCast(cell_h), .tagline_w = @intCast(tag_w), .tagline_h = @intCast(tag_h) }; const same_metrics = std.meta.eql(metrics, core.row_metrics); if (comptime pardes.pdf_enabled) { const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16))); const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16))); if (cols == core.screen_w and rows == core.screen_h and cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h and same_metrics) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h }, .row_metrics = metrics, } }); } else { if (cols == core.screen_w and rows == core.screen_h and same_metrics) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = metrics } }); } return true; } const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; const TouchPoint = struct { active: bool = false, id: u64 = 0, x: f32 = 0, y: f32 = 0, pressure: f32 = 1, trail: [max_touch_trail_points]TouchSample = @splat(.{}), trail_len: usize = 0, trail_next: usize = 0, }; const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; const Touch = struct { points: [max_touch_points]TouchPoint = @splat(.{}), scroll: struct { active: bool = false, ids: [2]u64 = .{ 0, 0 }, accum_y: f32 = 0, scrolled: bool = false, last_center: TouchNormPoint = .{}, } = .{}, click_count: u32 = 0, click_button: pardes.Mouse.Button = .middle, click_flash_frames: u8 = 0, fn findSlot(t: *const Touch, id: u64) ?usize { for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; return null; } fn updatePoint(t: *Touch, f: Finger) void { switch (f.kind) { .up, .cancel => if (t.findSlot(f.id)) |i| { t.points[i].active = false; }, .down, .motion => { const idx = t.findSlot(f.id) orelse blk: { for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; break :blk @as(usize, @intCast(f.id % max_touch_points)); }; const tp = &t.points[idx]; if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; tp.x = f.x; tp.y = f.y; tp.pressure = f.pressure; tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; }, } } fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { var sum: TouchNormPoint = .{}; for (ids) |id| { const i = t.findSlot(id) orelse return null; sum.x += t.points[i].x; sum.y += t.points[i].y; } return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; } fn syncPair(t: *Touch) void { var ids: [2]u64 = .{ 0, 0 }; var count: usize = 0; for (&t.points) |*tp| { if (!tp.active) continue; if (count >= 2) { t.scroll = .{}; return; } ids[count] = tp.id; count += 1; } if (count != 2) { t.scroll = .{}; return; } if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; } fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const tap = f.kind == .up and !t.scroll.scrolled; var center: ?TouchNormPoint = null; if (tap) { var sum: TouchNormPoint = .{}; var ok = true; for (t.scroll.ids) |id| { if (id == f.id) { sum.x += f.x; sum.y += f.y; } else if (t.findSlot(id)) |i| { sum.x += t.points[i].x; sum.y += t.points[i].y; } else ok = false; } center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; } t.scroll = .{}; return center; } fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; const delta_y = center.y - t.scroll.last_center.y; t.scroll.last_center = center; t.scroll.accum_y += delta_y; const ticks = takeScrollTicks(&t.scroll.accum_y); if (ticks == 0) return null; t.scroll.scrolled = true; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } }; fn takeScrollTicks(accum: *f32) i32 { var ticks: i32 = 0; while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; return ticks; } fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w); } fn handlePairFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { switch (f.kind) { .down => { t.updatePoint(f); t.syncPair(); }, .motion => { const had_pair = t.scroll.active; t.updatePoint(f); t.syncPair(); if (!had_pair) return; const res = t.noteMotion(f) orelse return; emitTouchScroll(in, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w); }, .up, .cancel => { const tap_center = t.finishPair(f); t.updatePoint(f); t.syncPair(); if (tap_center) |center| emitTouchClick(t, in, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w); }, } } fn emitTouchScroll(in: *Input, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { const cell = gridCellAtDimensions(in.core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w); const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; var remaining: u32 = @abs(ticks); while (remaining > 0) : (remaining -= 1) in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); in.post(.{ .touch_scroll = delta_y }); } fn emitTouchClick(t: *Touch, in: *Input, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { t.click_count +%= 1; t.click_button = button; t.click_flash_frames = touch_click_flash_max_frames; const cell = gridCellAtDimensions(in.core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w); in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); } fn normCell(norm: f32, win: f32, cell: f32) u16 { const px = std.math.clamp(norm, 0.0, 1.0) * win; return @intFromFloat(@max(0, @floor(px / cell))); } const Pty = struct { fd: c_int, pid: libc.pid_t, serial: u32, kill_at: i64 = 0, reader: ?std.Thread = null, stop: [2]c_int = .{ -1, -1 }, /// A command pane's child, watched to its exit (host_io.watchExit); /// the pty stays open until both its end of file and its exit. cmd: host_io.CommandWatch = .{}, }; /// A watched child exited: wake the loop, which takes it (`takeExits`). fn wakeForExit(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.wake(); } const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 }; const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32, failure: ?anyerror = null }, lsp: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, pipe: selection_pipe.Response, files_changed, fs_ready, fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .lsp => |l| if (l.rows) |rows| lsp_allocator.free(rows), .lsp_status => |t| lsp_allocator.free(t), .pipe => |response_value| { var response = response_value; response.deinit(gpa); }, .eof, .files_changed, .fs_ready => {}, } } }; const LspWorkers = struct { const capacity = 16; active: std.atomic.Value(usize) = .init(0), fn start(workers: *LspWorkers) bool { var count = workers.active.load(.monotonic); while (count < capacity) count = workers.active.cmpxchgWeak(count, count + 1, .monotonic, .monotonic) orelse return true; return false; } fn finish(workers: *LspWorkers) void { const previous = workers.active.fetchSub(1, .release); std.debug.assert(previous > 0); } fn wait(workers: *LspWorkers) void { var spins: u8 = 0; while (workers.active.load(.acquire) != 0) { spins +%= 1; if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); } } }; const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; const output_capacity = queue_capacity - pardes.MAX_PANES; const MessageBatch = struct { items: [queue_capacity + PipeTasks.capacity + 2]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { return batch.items[0..batch.len]; } }; const Queue = struct { gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator, lsp_workers: *LspWorkers, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push mutex: libc.pthread_mutex_t = .{}, space: libc.pthread_cond_t = .{}, items: [queue_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, files_changed: bool = false, readers: [pardes.MAX_PANES]?u32 = @splat(null), waiting: usize = 0, lsp_id: ?u32 = null, completions: [PipeTasks.capacity + 1]Msg = undefined, completion_len: usize = 0, fn lock(q: *Queue) void { std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); } fn unlock(q: *Queue) void { std.debug.assert(libc.pthread_mutex_unlock(&q.mutex) == .SUCCESS); } fn wake(q: *Queue) void { if (q.sdl_wake) { var sev = std.mem.zeroes(c.SDL_Event); sev.type = c.SDL_EVENT_USER; _ = c.SDL_PushEvent(&sev); } } fn acceptReader(q: *Queue, pane: u8, gen: u32) void { q.lock(); defer q.unlock(); std.debug.assert(q.readers[pane] == null); q.readers[pane] = gen; } fn cancelReader(q: *Queue, pane: u8, gen: u32) void { q.lock(); defer q.unlock(); if (q.readers[pane] != gen) return; q.readers[pane] = null; var i: usize = 0; while (i < q.len) { const msg = q.items[(q.head + i) % q.items.len]; const matches = switch (msg) { .output => |o| o.pane == pane and o.gen == gen, .eof => |e| e.pane == pane and e.gen == gen, else => false, }; if (matches) q.removeAt(i).deinit(q.gpa, q.lsp_allocator) else i += 1; } std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); } fn pushOutput(q: *Queue, pane: u8, gen: u32, bytes: []u8) bool { q.lock(); while (!q.closed and q.readers[pane] == gen and q.len >= output_capacity) { q.waiting += 1; std.debug.assert(libc.pthread_cond_wait(&q.space, &q.mutex) == .SUCCESS); q.waiting -= 1; } if (q.closed or q.readers[pane] != gen) { q.unlock(); q.gpa.free(bytes); return false; } q.items[(q.head + q.len) % q.items.len] = .{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }; q.len += 1; q.unlock(); q.wake(); return true; } fn removeAt(q: *Queue, offset: usize) Msg { const removed = q.items[(q.head + offset) % q.items.len]; var i = offset; while (i + 1 < q.len) : (i += 1) q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; q.len -= 1; return removed; } fn discardLsp(q: *Queue) void { for (q.completions[0..q.completion_len], 0..) |msg, i| if (msg == .lsp) { msg.deinit(q.gpa, q.lsp_allocator); q.completion_len -= 1; std.mem.copyForwards(Msg, q.completions[i..q.completion_len], q.completions[i + 1 .. q.completion_len + 1]); return; }; } fn push(q: *Queue, m: Msg) void { if (m == .output) { _ = q.pushOutput(m.output.pane, m.output.gen, m.output.bytes); return; } q.lock(); if (q.closed) { q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } switch (m) { .files_changed => q.files_changed = true, .lsp, .pipe => { if (m == .lsp) { if (q.lsp_id != m.lsp.id) { q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } q.discardLsp(); } std.debug.assert(q.completion_len < q.completions.len); q.completions[q.completion_len] = m; q.completion_len += 1; }, else => { if (m == .eof) { if (q.readers[m.eof.pane] != m.eof.gen) { q.unlock(); return; } std.debug.assert(q.len < q.items.len); } else if (q.len >= output_capacity) { q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } q.items[(q.head + q.len) % q.items.len] = m; q.len += 1; }, } q.unlock(); q.wake(); } fn take(q: *Queue) MessageBatch { q.lock(); defer q.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; batch.len += 1; q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; if (q.files_changed) { batch.items[batch.len] = .files_changed; batch.len += 1; q.files_changed = false; } @memcpy(batch.items[batch.len..][0..q.completion_len], q.completions[0..q.completion_len]); batch.len += q.completion_len; q.completion_len = 0; std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); return batch; } fn discardCompletions(q: *Queue) void { q.lock(); defer q.unlock(); q.lsp_id = null; for (q.completions[0..q.completion_len]) |msg| msg.deinit(q.gpa, q.lsp_allocator); q.completion_len = 0; } fn close(q: *Queue) void { q.lock(); q.closed = true; q.files_changed = false; q.readers = @splat(null); std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); q.unlock(); q.discardCompletions(); q.lock(); defer q.unlock(); while (q.len > 0) { const m = q.items[q.head]; m.deinit(q.gpa, q.lsp_allocator); q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } fn deinit(q: *Queue) void { q.close(); std.debug.assert(q.waiting == 0); std.debug.assert(libc.pthread_cond_destroy(&q.space) == .SUCCESS); std.debug.assert(libc.pthread_mutex_destroy(&q.mutex) == .SUCCESS); } }; test "GUI completion survives a full output queue" { const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 7 }; defer queue.deinit(); queue.acceptReader(0, 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "output"), } }); for (0..PipeTasks.capacity) |id| queue.push(.{ .pipe = .{ .id = @intCast(id), .success = false, .outputs = &.{}, .failure = .{ .stderr = try gpa.dupe(u8, "pipe failure") }, } }); queue.push(.{ .lsp = .{ .id = 7, .rows = try gpa.dupe(u8, "completion") } }); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(output_capacity + PipeTasks.capacity + 1, batch.len); for (batch.items[0..output_capacity]) |msg| { try std.testing.expect(msg == .output); try std.testing.expectEqualStrings("output", msg.output.bytes); } for (batch.items[output_capacity..][0..PipeTasks.capacity], 0..) |msg, id| { try std.testing.expect(msg == .pipe); try std.testing.expectEqual(@as(u32, @intCast(id)), msg.pipe.id); } var found = false; for (batch.slice()) |msg| if (msg == .lsp) { try std.testing.expectEqual(@as(u32, 7), msg.lsp.id); try std.testing.expectEqualStrings("completion", msg.lsp.rows.?); found = true; }; try std.testing.expect(found); try std.testing.expectEqual(@as(usize, 0), queue.take().len); } const PtyTests = struct { fn waitBlocked(queue: *Queue) !void { const until = shellClock() + 2_000; while (shellClock() < until) { queue.lock(); const waiting = queue.waiting; queue.unlock(); if (waiting != 0) return; try std.testing.io.sleep(.fromMilliseconds(1), .awake); } return error.ReaderDidNotBlock; } fn tail(queue: *Queue, bytes: []u8) void { if (queue.pushOutput(0, 1, bytes)) queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); } }; test "GUI PTY backpressure retains byte order and tail before EOF" { const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); queue.acceptReader(0, 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); const tail = try gpa.dupe(u8, "tail"); var thread: ?std.Thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { gpa.free(tail); return err; }; defer { queue.cancelReader(0, 1); if (thread) |owned| owned.join(); } try PtyTests.waitBlocked(&queue); queue.push(.files_changed); queue.push(.files_changed); var first = queue.take(); defer for (first.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(output_capacity + 1, first.len); for (first.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); try std.testing.expect(first.items[output_capacity] == .files_changed); thread.?.join(); thread = null; var last = queue.take(); defer for (last.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 2), last.len); try std.testing.expectEqualStrings("tail", last.items[0].output.bytes); try std.testing.expect(last.items[1] == .eof); try std.testing.expectEqual(@as(usize, 0), queue.waiting); } test "GUI PTY backpressure cancellation and close release owned producers" { const gpa = std.testing.allocator; for ([_]bool{ false, true }) |close| { var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); queue.acceptReader(0, 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); const tail = try gpa.dupe(u8, "cancelled"); const thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { gpa.free(tail); return err; }; defer { queue.cancelReader(0, 1); thread.join(); } try PtyTests.waitBlocked(&queue); if (close) queue.close() else queue.cancelReader(0, 1); if (!close) { queue.acceptReader(0, 2); queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); try std.testing.expect(queue.pushOutput(0, 2, try gpa.dupe(u8, "replacement"))); queue.push(.{ .eof = .{ .pane = 0, .gen = 2 } }); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 2), batch.len); try std.testing.expectEqualStrings("replacement", batch.items[0].output.bytes); try std.testing.expectEqual(@as(u32, 2), batch.items[1].eof.gen); } else try std.testing.expectEqual(@as(usize, 0), queue.take().len); } } test "GUI PTY EOF reserve cannot evict terminal output" { const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); for (0..pardes.MAX_PANES) |pane| queue.acceptReader(@intCast(pane), 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); for (0..pardes.MAX_PANES) |pane| queue.push(.{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(queue_capacity, batch.len); for (batch.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); for (batch.items[output_capacity..batch.len], 0..) |msg, pane| try std.testing.expectEqual(pane, msg.eof.pane); } test "GUI PTY reader delivers a real shell tail before EOF and joins on close" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(1); var shell: Shell = undefined; shell.core = core; shell.gpa = gpa; shell.ptys = &ptys; shell.gens = &gens; shell.queue = &queue; shell.retired_shells = @splat(.{}); defer shell.shutdownPtys(); const rcs: host_io.Shell.PromptFiles = .{}; const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); try std.testing.expect(host_io.writeFd(child.file.handle, "printf '\\120\\101\\122\\104\\105\\123\\055\\124\\101\\111\\114'; exit\n")); var bytes: std.ArrayList(u8) = .empty; defer bytes.deinit(gpa); var eof = false; const until = shellClock() + 2_000; while (!eof and shellClock() < until) { var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); for (batch.slice()) |msg| switch (msg) { .output => |o| { try std.testing.expect(!eof); try bytes.appendSlice(gpa, o.bytes); }, .eof => |e| { try std.testing.expect(e.failure == null); eof = true; }, else => return error.UnexpectedMessage, }; if (!eof) try std.testing.io.sleep(.fromMilliseconds(1), .awake); } try std.testing.expect(eof); try std.testing.expect(std.mem.indexOf(u8, bytes.items, "PARDES-TAIL") != null); shell.closePty(0); try std.testing.expect(queue.readers[0] == null); try std.testing.expect(ptys[0] == null or ptys[0].?.reader == null); try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); } test "GUI PTY Restore joins a real reader waiting for queue space" { const gpa = std.testing.allocator; var core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); try dump.dumpState(core); var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); queue.acceptReader(1, 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 1, .gen = 1, .bytes = try gpa.dupe(u8, "other pane") } }); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(1); var shell: Shell = undefined; shell.core = core; shell.gpa = gpa; shell.ptys = &ptys; shell.gens = &gens; shell.queue = &queue; shell.retired_shells = @splat(.{}); defer shell.shutdownPtys(); const rcs: host_io.Shell.PromptFiles = .{}; const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); try PtyTests.waitBlocked(&queue); const old_serial = core.panes[0].?.serial; const replacement = try dump.restore(core, core.dump_out.?, "test"); shell.stopPtys(); core.deinit(); core = replacement; shell.core = replacement; try std.testing.expect(core.panes[0].?.serial != old_serial); try std.testing.expectEqual(@as(usize, 0), queue.waiting); try std.testing.expect(queue.readers[0] == null); try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); queue.cancelReader(1, 1); queue.acceptReader(0, gens[0]); queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); try std.testing.expect(queue.pushOutput(0, gens[0], try gpa.dupe(u8, "fresh"))); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 1), batch.len); try std.testing.expectEqualStrings("fresh", batch.items[0].output.bytes); } test "GUI PTY deletion joins an idle reader and retains only its owned child for reaping" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(1); var shell: Shell = undefined; shell.core = core; shell.gpa = gpa; shell.ptys = &ptys; shell.gens = &gens; shell.queue = &queue; shell.retired_shells = @splat(.{}); defer shell.shutdownPtys(); const rcs: host_io.Shell.PromptFiles = .{}; const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); try core.removePane(0, null); shell.reconcilePtys(); try std.testing.expect(queue.readers[0] == null); try std.testing.expectEqual(@as(usize, 0), queue.waiting); try std.testing.expectEqual(@as(usize, 0), queue.take().len); try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); for (shell.retired_shells) |retired| try std.testing.expect(retired.pid == 0 or retired.pid == child.pid); shell.shutdownPtys(); try std.testing.expectEqual(@as(libc.pid_t, -1), libc.waitpid(child.pid, null, libc.W.NOHANG)); try std.testing.expectEqual(libc.E.CHILD, libc.errno(-1)); } test "GUI PTY file watcher stops and joins without closing its watched descriptor" { if (!file_watch.supported) return error.SkipZigTest; const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); const fd = file_watch.init(true); if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); const stop = try stopPipe(); defer for (stop) |pipe_fd| { _ = libc.close(pipe_fd); }; const thread = try std.Thread.spawn(.{}, watchThread, .{ fd, stop[0], &queue }); _ = host_io.writeFd(stop[1], "x"); thread.join(); try std.testing.expect(libc.fcntl(fd, libc.F.GETFD) >= 0); try std.testing.expectEqual(@as(usize, 0), queue.take().len); } test "GUI completion failure survives backlog and leaves the document unchanged" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("abc"); pane.body.cur_col = 1; core.lspRequest(core.active, .completion, ""); const id = core.lsp_wait.?.id; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = id }; defer queue.deinit(); queue.acceptReader(0, 1); for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "output"), } }); queue.push(.{ .lsp = .{ .id = id, .rows = null } }); var gens: [pardes.MAX_PANES]u32 = @splat(1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var shell: Shell = undefined; shell.core = core; shell.queue = &queue; shell.gpa = gpa; shell.lsp_allocator = gpa; shell.gens = &gens; shell.ptys = &ptys; shell.retired_shells = @splat(.{}); shell.saw_event = false; shell.drainQueue(); try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expect(shell.saw_event); } test "GUI completions retain their arrival order across LSP and pipes" { const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 2 }; defer queue.deinit(); queue.push(.{ .pipe = .{ .id = 1, .success = true, .outputs = &.{} } }); queue.push(.{ .lsp = .{ .id = 2, .rows = try gpa.dupe(u8, "second") } }); queue.push(.{ .pipe = .{ .id = 3, .success = false, .outputs = &.{} } }); queue.push(.{ .lsp = .{ .id = 1, .rows = try gpa.dupe(u8, "late") } }); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 3), batch.len); try std.testing.expectEqual(@as(u32, 1), batch.items[0].pipe.id); try std.testing.expectEqual(@as(u32, 2), batch.items[1].lsp.id); try std.testing.expectEqual(@as(u32, 3), batch.items[2].pipe.id); } test "GUI completion reset frees queued payloads and rejects late LSP workers" { const gpa = std.testing.allocator; var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 9 }; defer queue.deinit(); queue.acceptReader(0, 1); queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "retained") } }); queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "current") } }); queue.push(.{ .lsp = .{ .id = 8, .rows = try gpa.dupe(u8, "late old request") } }); try std.testing.expectEqualStrings("current", queue.completions[0].lsp.rows.?); for (0..PipeTasks.capacity) |id| { const outputs = try gpa.alloc([]u8, 1); outputs[0] = try gpa.dupe(u8, "cancelled result"); queue.push(.{ .pipe = .{ .id = @intCast(id), .success = true, .outputs = outputs } }); } queue.discardCompletions(); queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late before Restore") } }); try std.testing.expectEqual(@as(usize, 0), queue.completion_len); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 1), batch.len); try std.testing.expectEqualStrings("retained", batch.items[0].output.bytes); queue.lsp_id = 10; queue.push(.{ .lsp = .{ .id = 10, .rows = null } }); queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late after Restore") } }); var next = queue.take(); defer for (next.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 1), next.len); try std.testing.expectEqual(@as(u32, 10), next.items[0].lsp.id); try std.testing.expect(next.items[0].lsp.rows == null); queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "closing result") } }); queue.push(.{ .pipe = .{ .id = 17, .success = false, .outputs = &.{}, .failure = .{ .stderr = try gpa.dupe(u8, "closing failure") }, } }); queue.close(); queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "late closed result") } }); queue.push(.{ .pipe = .{ .id = 18, .success = false, .outputs = &.{}, .failure = .{ .stderr = try gpa.dupe(u8, "late closed failure") }, } }); try std.testing.expectEqual(@as(usize, 0), queue.take().len); } fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); const copy = q.lsp_allocator.dupe(u8, text) catch return; q.push(.{ .lsp_status = copy }); } fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, stop: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; var failure: ?anyerror = null; while (true) { var fds = [_]libc.pollfd{ .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, }; if (libc.poll(&fds, fds.len, -1) < 0) { if (libc.errno(-1) == .INTR) continue; failure = error.PollFailed; break; } if (fds[0].revents != 0) return; if (fds[1].revents == 0) continue; const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; if (libc.errno(n) != .IO) failure = error.ReadFailed; break; } if (n == 0) break; const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch |err| { failure = err; break; }; if (!q.pushOutput(pane, gen, bytes)) return; } q.push(.{ .eof = .{ .pane = pane, .gen = gen, .failure = failure } }); } fn stopPipe() ![2]c_int { var fds: [2]c_int = undefined; if (libc.pipe(&fds) != 0) return error.PipeFailed; errdefer for (fds) |fd| { _ = libc.close(fd); }; for (fds) |fd| if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.PipeFailed; return fds; } fn spawnReader(gpa: std.mem.Allocator, pt: *Pty, pane: u8, gen: u32, q: *Queue) !void { std.debug.assert(pt.reader == null and pt.fd >= 0); const stop = try stopPipe(); errdefer for (stop) |fd| { _ = libc.close(fd); }; q.acceptReader(pane, gen); errdefer q.cancelReader(pane, gen); pt.reader = try std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, stop[0], pane, gen, q }); pt.stop = stop; } fn watchThread(fd: c_int, stop: c_int, q: *Queue) void { while (true) { var fds = [_]libc.pollfd{ .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, }; if (libc.poll(&fds, fds.len, -1) < 0) { if (libc.errno(-1) == .INTR) continue; return; } if (fds[0].revents != 0) return; if ((fds[1].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL)) != 0) return; if (fds[1].revents != 0 and file_watch.drain(fd)) q.push(.files_changed); } } fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *host_io.Lsp.Job, q: *Queue) void { defer workers.finish(); host_io.Lsp.work(lsp_allocator, job, q, pushLspRows); } fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); q.push(.{ .lsp = .{ .id = id, .rows = rows } }); } fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_io.Lsp.Request) void { const lsp_allocator = q.lsp_allocator; q.lock(); q.lsp_id = e.id; q.discardLsp(); q.unlock(); if (!q.lsp_workers.start()) { core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); return core.reportError(e.pane, "lsp", error.WorkersBusy); } const job = host_io.Lsp.snapshot(lsp_allocator, core, e) catch |err| { q.lsp_workers.finish(); core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); return core.reportError(e.pane, "lsp", err); }; const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch |err| { q.lsp_workers.finish(); job.free(lsp_allocator); core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); return core.reportError(e.pane, "lsp", err); }; th.detach(); } test "GUI LSP worker limit rejects before allocation and recovers after owned workers exit" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("abc"); pane.body.cur_col = 1; var workers: LspWorkers = .{}; var gate: std.Io.Event = .unset; var threads: [LspWorkers.capacity]?std.Thread = @splat(null); defer { gate.set(std.testing.io); for (threads) |thread| if (thread) |owned| owned.join(); workers.wait(); } for (&threads) |*thread| { try std.testing.expect(workers.start()); thread.* = std.Thread.spawn(.{}, struct { fn run(active: *LspWorkers, ready: *std.Io.Event) void { defer active.finish(); ready.waitUncancelable(std.testing.io); } }.run, .{ &workers, &gate }) catch |err| { workers.finish(); return err; }; } try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); try std.testing.expect(!workers.start()); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); var queue: Queue = .{ .gpa = gpa, .lsp_allocator = failing.allocator(), .lsp_workers = &workers, .sdl_wake = false }; defer queue.deinit(); core.lspRequest(core.active, .completion, ""); const rejected = core.lsp_wait.?.id; queue.lsp_id = rejected -% 1; queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "obsolete queued result") } }); spawnLsp(core, &queue, .{ .id = rejected, .kind = .completion, .pane = @intCast(core.active), .offset = 1, .arg = "", }); try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "workers busy") != null); try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); try std.testing.expect(!failing.has_induced_failure); try std.testing.expectEqual(rejected, queue.lsp_id.?); try std.testing.expectEqual(@as(usize, 0), queue.completion_len); queue.push(.{ .lsp = .{ .id = rejected -% 1, .rows = try gpa.dupe(u8, "obsolete late result") } }); try std.testing.expectEqual(@as(usize, 0), queue.completion_len); try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); gate.set(std.testing.io); workers.wait(); try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); queue.lsp_allocator = gpa; core.lspRequest(core.active, .status, ""); const accepted = core.lsp_wait.?.id; spawnLsp(core, &queue, .{ .id = accepted, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }); workers.wait(); var batch = queue.take(); defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); try std.testing.expectEqual(@as(usize, 1), batch.len); try std.testing.expectEqual(accepted, batch.items[0].lsp.id); try std.testing.expect(batch.items[0].lsp.rows != null); core.update(.{ .lsp_resp = .{ .id = accepted, .rows = batch.items[0].lsp.rows } }); try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); try std.testing.expectEqualStrings("abc", pane.file.?.content); } fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { defer job.deinit(gpa); const response = selection_pipe.runJob(gpa, io, job); q.push(.{ .pipe = response }); } fn spawnPipe( core: *pardes.Pardes, io: std.Io, gpa: std.mem.Allocator, q: *Queue, tasks: *PipeTasks, id: u32, ) void { if (tasks.len == tasks.items.len) { core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = core.pipe.pipeRequest(id) orelse return; const job = selection_pipe.Job.copy(gpa, view) catch |err| { core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return core.reportError(core.active, "pipe", err); }; const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch |err| { job.deinit(gpa); core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return core.reportError(core.active, "pipe", err); }; std.debug.assert(tasks.add(.{ .id = id, .future = future })); } test "GUI worker setup failures finish matching LSP and pipe requests" { const gpa = std.testing.allocator; var failing_vtable = std.testing.io.vtable.*; failing_vtable.concurrent = std.Io.failingConcurrent; const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; for (0..2) |failure| { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("abc"); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), }); var workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = failing.allocator(), .lsp_workers = &workers, .sdl_wake = false, }; defer queue.deinit(); var tasks: PipeTasks = .{}; core.lspRequest(core.active, .status, ""); const req: host_io.Lsp.Request = .{ .id = core.lsp_wait.?.id, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }; if (failure == 0) { queue.lsp_id = req.id -% 1; queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "old queued result") } }); spawnLsp(core, &queue, req); try std.testing.expectEqual(req.id, queue.lsp_id.?); try std.testing.expectEqual(@as(usize, 0), queue.completion_len); queue.push(.{ .lsp = .{ .id = req.id -% 1, .rows = try gpa.dupe(u8, "late old result") } }); try std.testing.expectEqual(@as(usize, 0), queue.completion_len); } else { var shell: Shell = undefined; shell.core = core; shell.threads_ok = false; lsp(&shell, req); } try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); pane.body.cur_col = 2; pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; core.update(.{ .key = .{ .cp = '|' } }); core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); core.update(.{ .key = .{ .cp = pardes.Key.enter } }); const pipe_id = core.pipe.wait.?.id; spawnPipe(core, failing_io, failing.allocator(), &queue, &tasks, pipe_id); try std.testing.expect(core.pipe.wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); try std.testing.expectEqual(@as(usize, 0), tasks.len); try std.testing.expectEqual(@as(usize, 0), queue.len); } } const Gui = struct { window: *c.SDL_Window, device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, /// The cell pipeline, blending premultiplied over what is drawn under: /// for a frame with a theme's tag plaques, whose blank tag cells let /// them show (tagPlaques). pipeline_over: *c.SDL_GPUGraphicsPipeline, decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_sampler: *c.SDL_GPUSampler, atlas_xfer: *c.SDL_GPUTransferBuffer, vbuf: ?*c.SDL_GPUBuffer = null, vxfer: ?*c.SDL_GPUTransferBuffer = null, vbuf_cells: u32 = 0, overlay_vbuf: *c.SDL_GPUBuffer, overlay_vxfer: *c.SDL_GPUTransferBuffer, overlay_vertices: []OverlayVertex, image_vbuf: *c.SDL_GPUBuffer, image_vxfer: *c.SDL_GPUTransferBuffer, image_capacity: u32 = 0, native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty, prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, /// renderFrame's cover of the grid (coverFrame): this frame's, then the /// frame a panel transition is leaving. cover: std.ArrayListUnmanaged(Cover) = .empty, /// renderFrame's decor instances, group by group (buildDecor). decor: std.ArrayListUnmanaged(CellInstance) = .empty, font: *c.UIFont, font_bytes: []u8 = &.{}, font_name: [255]u8 = @splat(0), font_name_len: u8 = 0, fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null), fallback_count: usize = 0, px: f32, tagline_px: f32, tagline_percent: u8, tagline_width: u32, tagline_height: u32, attached_layers: ?*const [pardes.MAX_PANES]pardes.Layer = null, attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.Layer = null, cell_w: u32, cell_h: u32, ascent: i32, tagline_baseline: i32, atlas_stage: []u8, glyphs: std.AutoHashMap(GlyphKey, Slot), pen_x: u32 = 0, pen_y: u32 = 0, /// The atlas rows drawn into since the last upload, [top, bottom): the /// whole atlas at first, since the texture starts undefined. atlas_dirty_top: u32 = 0, atlas_dirty_bottom: u32 = atlas_h, space_slot: Slot = .{ .u = 0, .v = 0 }, words: Words = .{}, touch: Touch = .{}, pet_state: pet.State = .{}, live_ctrl: bool = false, live_alt: bool = false, scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) scroll_body_h: u16 = 0, // and its BODY rows, which a taller tag leaves fewer of scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since scroll_delta: f32 = 0, // finite raw distance waiting for the render batch scroll_lag: f32 = 0, // picture position - core position, in rows scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, /// G6: a wheel's notches, whole lines, scroll the core at once and the /// picture follows on a spring (Glide), never the other way round. scroll_notch: i32 = 0, glide: Glide = .{}, /// The cursor's blink (blinkNow): when the frame drawn last arrived, /// a redraw owed for an edge, and this frame's cursor mode and alpha. frame_arrival_ns: u64 = 0, blink_due: bool = false, no_blink: bool = false, cursor_quad_alpha: f32 = 0, scene_tex: ?*c.SDL_GPUTexture = null, scene_tex_w: u32 = 0, scene_tex_h: u32 = 0, scene_failures: u8 = 0, scene_target_failed: bool = false, /// The post chain, run over the frame in scene_tex. post: Post = .{}, pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, box_cursor: ?*c.SDL_Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, capture: bool = false, /// Pixels the capture holds past the grid (PARDES_TEST_PAD). capture_pad: u32 = 0, capture_dir: []const u8 = "", /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a /// feel review's frame series (docs/render-pipeline.md §8.4). capture_series: bool = false, captured: u32 = 0, capture_tex: ?*c.SDL_GPUTexture = null, capture_tex_w: u32 = 0, capture_tex_h: u32 = 0, capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, soft_present: bool = false, transparent: bool = false, surface_opacity: bool = false, applied_window_opacity: u8 = 100, soft_renderer: ?*c.SDL_Renderer = null, soft_texture: ?*c.SDL_Texture = null, soft_tex_w: u32 = 0, soft_tex_h: u32 = 0, gamepad: ?*c.SDL_Gamepad = null, pad_x: f32 = 0, pad_y: f32 = 0, pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) deck: deck.Deck = .{}, }; fn setGuiFontName(g: *Gui, fallback: []const u8) void { const name = if (c.ui_font_name(g.font)) |name_z| std.mem.span(name_z) else fallback; const len = @min(name.len, g.font_name.len); @memcpy(g.font_name[0..len], name[0..len]); g.font_name_len = @intCast(len); } fn guiFontName(g: *const Gui) []const u8 { return g.font_name[0..g.font_name_len]; } fn observeGuiFont(g: *const Gui, core: *pardes.Pardes) void { const size: u16 = @intFromFloat(@round(g.px * 100.0)); _ = core.observeFont(guiFontName(g), size, .pixels); } fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void { const size: u16 = @intFromFloat(@round(g.px * 100.0)); _ = core.acknowledgeFont(guiFontName(g), size, .pixels); } fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| { g.fallbacks[0] = .{ .face = face }; g.fallback_count = 1; } var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); for (fonts.fallbacks(arena_state.allocator())) |candidate| { if (g.fallback_count == g.fallbacks.len) break; const bytes = filesystem.readFile(gpa, candidate.path) catch continue; const len = std.math.cast(c_int, bytes.len) orelse { gpa.free(bytes); continue; }; const face = c.ui_font_new(bytes.ptr, len) orelse { gpa.free(bytes); continue; }; g.fallbacks[g.fallback_count] = .{ .face = face, .bytes = bytes }; g.fallback_count += 1; } } fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { for (g.fallbacks[0..g.fallback_count]) |loaded| { const fallback = loaded.?; c.ui_font_free(fallback.face); if (fallback.bytes.len != 0) gpa.free(fallback.bytes); } } const FaceGlyph = struct { face: u8, glyph: u32 }; /// The face and glyph that draw `cp`, the face as a GlyphKey face: 0 is the /// primary font and i the fallback i - 1. A codepoint no face has draws the /// primary's .notdef. fn glyphForCodepoint(g: *const Gui, cp: u32) FaceGlyph { const primary = c.ui_font_glyph(g.font, @intCast(cp)); if (primary != 0) return .{ .face = 0, .glyph = primary }; for (g.fallbacks[0..g.fallback_count], 1..) |loaded, face| { const glyph = c.ui_font_glyph(loaded.?.face, @intCast(cp)); if (glyph != 0) return .{ .face = @intCast(face), .glyph = glyph }; } return .{ .face = 0, .glyph = 0 }; } const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null, rule_above: bool = false }; /// How near a row's top edge a press is on the rule drawn there between two /// panes: the rule's 2 px and a pixel either side, in logical pixels (dp). const rule_grab_px: f32 = 3; const WindowGeometry = struct { window_w: f32, window_h: f32, pixel_w: f32, pixel_h: f32, }; fn windowGeometry(window: *c.SDL_Window) WindowGeometry { var ww: c_int = 0; var wh: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSize(window, &ww, &wh); _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); return .{ .window_w = @floatFromInt(@max(ww, 1)), .window_h = @floatFromInt(@max(wh, 1)), .pixel_w = @floatFromInt(@max(pw, 1)), .pixel_h = @floatFromInt(@max(ph, 1)), }; } const GridCells = struct { cols: u16, rows: u16 }; fn windowCells(g: *const Gui) GridCells { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); return .{ .cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))), .rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))), }; } const Point = struct { x: f32, y: f32 }; fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) Point { return .{ .x = x * geometry.pixel_w / geometry.window_w, .y = y * geometry.pixel_h / geometry.window_h, }; } test "high-density window coordinates map once to render pixels" { const geometry: WindowGeometry = .{ .window_w = 560, .window_h = 360, .pixel_w = 1120, .pixel_h = 720, }; try std.testing.expectEqual(Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); } fn fixedCellLayout(g: *const Gui) CellLayout { return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; } fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { const body_w: f32 = @floatFromInt(g.cell_w); const tag_w: f32 = @floatFromInt(g.tagline_width); return .{ .w = tag_w, .h = @floatFromInt(g.cell_h), .x_off = origin_col * (body_w - tag_w), .y_off = 0, }; } fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { // Canonical tag cells are physical grips; text is drawn by TagLayer. return compactTaglineLayout(g, @floatFromInt(col)); } const boxContains = pardes.animation.Box.contains; /// What one tier draws, or one track in tiers 2 and 3 (docs/render-pipeline.md /// §3.4), in this order: its cells, its images, then its decor. const Group = struct { track: ?pardes.animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, image_count: u32 = 0, decor_start: u32 = 0, decor_count: u32 = 0, /// The first `under_count` of the group's decor are drawn before its /// cells, under them (a theme's tag plaques, whose cells let them show). under_count: u32 = 0, }; /// The frame's draw groups, bottom to top: tier 0 (the page and the panes /// standing still), a group for each track in paint order (tier 2 moving and /// opening, tier 3 closing), tier 4 (floating: the notices, then the guides /// and the debug box, which the core paints over them) and tier 5 (ink: the /// bar cursors; the pet and the touch overlay draw after it). Tier 1 is the /// focus lift, which does not exist yet. const Groups = struct { items: [pardes.MAX_PANES * 2 + 4]Group = @splat(.{}), /// Groups 1 through `tracks` are the tracks'. tracks: usize = 0, notices: usize = 1, guides: usize = 2, ink: usize = 3, len: usize = 4, }; fn makeGroups(tracks: []const pardes.animation.Track, has_diff: bool) Groups { var groups: Groups = .{}; for (tracks) |track| { if (!track.active()) continue; if (track.effect.needsPreviousGrid() and !has_diff) continue; std.debug.assert(groups.tracks < pardes.MAX_PANES * 2); groups.tracks += 1; groups.items[groups.tracks].track = track; } groups.notices = groups.tracks + 1; groups.guides = groups.tracks + 2; groups.ink = groups.tracks + 3; groups.len = groups.tracks + 4; return groups; } /// The group of the track whose pane covers this cell, else tier 0. fn groupAt(groups: *const Groups, col: u16, row: u16) usize { for (groups.items[1..][0..groups.tracks], 1..) |group, index| { if (group.track.?.phase == .closing) continue; if (boxContains(group.track.?.to, col, row)) return index; } return 0; } fn groupOf(groups: *const Groups, serial: u32) usize { for (groups.items[1..][0..groups.tracks], 1..) |group, index| if (group.track.?.serial == serial) return index; return 0; } /// A notice floats over the panes and its pane's picture (tier 4) while /// nothing moves; while anything does it stays in its own pane's group, under /// whatever pane slides, opens or closes over it. fn noticeGroup(groups: *const Groups, layer: *const pardes.Layer) usize { if (groups.tracks > 0) return groupAt(groups, layer.viewport.x, layer.viewport.y); return groups.notices; } /// What draws a grid cell, marked once a frame from the layers and the /// regions (coverFrame), so the cell loops read it rather than search. const Cover = packed struct(u16) { /// A layer draws it; the grid's copy is not drawn. layer: bool = false, /// Under a guide or the debug box: drawn from the grid over the panes. floating: bool = false, /// In a pane's grip, `grip_offset` cells from its left: the grip's /// glyph is the cell that far to the left. grip: bool = false, grip_offset: u4 = 0, /// In the focused pane's or column's tag rows. focus: bool = false, /// At a column's left edge, where a tag's anchor is inset. anchor: bool = false, /// In a dirty pane's grip. dirty: bool = false, /// In a pane's grip (not a column's). pane_grip: bool = false, /// In a grip's first row, where its button is. grip_top: bool = false, _: u4 = 0, }; /// Marks the grid cells a body or tag layer covers, which the layers paint /// themselves, and what the regions say of the rest. Filling each rectangle /// once replaces scanning every layer for every cell, which was a third of /// the gui's CPU on a full screen of text. fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer, regions: []const pardes.Region) void { @memset(cover, .{}); const mark = struct { fn rect(into: []Cover, width: u16, height: u16, r: pardes.Rect, comptime field: []const u8, value: anytype) void { const right = @min(width, @as(u32, r.x) + r.w); if (r.x >= right) return; var y: u32 = r.y; while (y < @min(height, @as(u32, r.y) + r.h)) : (y += 1) for (into[y * width ..][r.x..right]) |*cell| { @field(cell, field) = value; }; } }; for (body_layers) |*layer| { if (layer.rows != 0) mark.rect(cover, cols, rows, layer.viewport, "layer", true); } for (tag_layers) |*layer| { if (layer.rows != 0) mark.rect(cover, cols, rows, .{ .x = layer.viewport.x, .y = layer.viewport.y, .w = layer.viewport.w, .h = layer.rows }, "layer", true); } // The column bar's rows, which the focused column's tint spans. var bar: ?pardes.Rect = null; for (regions) |region| { if (region.kind == .column_grip) bar = region.rect; } for (regions) |region| switch (region.kind) { .grip, .column_grip => { mark.rect(cover, cols, rows, region.rect, "grip", true); for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true); if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true); mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true); }, .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), .column => { mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = 0, .w = 1, .h = rows }, "anchor", true); if (region.active) if (bar) |b| mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = b.y, .w = region.rect.w, .h = b.h }, "focus", true); }, .guide, .debug => mark.rect(cover, cols, rows, region.rect, "floating", true), else => {}, }; } fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y))); return @as(u32, row -| y0) << 16 | @as(u32, col -| x0); } fn panelGridSize(track: pardes.animation.Track) u32 { const source = track.contentBox(); const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w)))); const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h)))); return @as(u32, rows) << 16 | @as(u32, cols); } fn ndcBox(box: pardes.animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { const px0 = layout.x_off + box.x * layout.w; const py0 = layout.y_off + box.y * layout.h; const px1 = px0 + box.w * layout.w; const py1 = py0 + box.h * layout.h; return .{ px0 / win_w * 2.0 - 1.0, 1.0 - py0 / win_h * 2.0, px1 / win_w * 2.0 - 1.0, 1.0 - py1 / win_h * 2.0, }; } fn setTransitionFields( instance: anytype, track: ?pardes.animation.Track, layout: CellLayout, win_w: f32, win_h: f32, cell_coord: u32, ) void { const active = track orelse { instance.panel_x0 = 0; instance.panel_y0 = 0; instance.panel_x1 = 0; instance.panel_y1 = 0; instance.present_x0 = 0; instance.present_y0 = 0; instance.present_x1 = 0; instance.present_y1 = 0; instance.effect = 0; instance.progress_bits = @bitCast(@as(f32, 1)); instance.serial = 0; instance.cell_coord = cell_coord; return; }; const final = ndcBox(active.contentBox(), layout, win_w, win_h); const presented = ndcBox(active.visualBox(), layout, win_w, win_h); instance.panel_x0 = final[0]; instance.panel_y0 = final[1]; instance.panel_x1 = final[2]; instance.panel_y1 = final[3]; instance.present_x0 = presented[0]; instance.present_y0 = presented[1]; instance.present_x1 = presented[2]; instance.present_y1 = presented[3]; instance.effect = @intFromEnum(active.effect); instance.progress_bits = @bitCast(active.amount()); instance.serial = active.serial; instance.cell_coord = cell_coord; // A vertical transition shows its pane only inside the box it grows // into, in whole pixels. if (@hasField(@TypeOf(instance.*), "clip_x0") and active.effect == .vertical) { const box = active.contentBox(); instance.clip_x0 = std.math.clamp(@floor(box.x * layout.w), 0, win_w); instance.clip_y0 = std.math.clamp(@floor(box.y * layout.h), 0, win_h); instance.clip_x1 = std.math.clamp(@ceil((box.x + box.w) * layout.w), instance.clip_x0, win_w); instance.clip_y1 = std.math.clamp(@ceil((box.y + box.h) * layout.h), instance.clip_y0, win_h); } } pub const run = runNative; fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void { const gpa = init.gpa; const env = init.environ_map; if (env.get("PARDES_TEST_GRID") != null) { if (attach != null) { log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{}); return error.AttachNeedsWindow; } return runGrid(init, opts_in); } const test_mode = env.get("PARDES_TEST") != null; const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); if (test_mode and capture_dir == null) { log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); return error.SdlInit; } if (test_mode) if (env.get("PARDES_TEST_LATENCY")) |path| { var path_buf: [4096]u8 = undefined; if (std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0)) |z| latency_fd = libc.open(z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .CLOEXEC = true }, @as(libc.mode_t, 0o644)) else |_| {} }; // SDL frees with whichever functions are current, so they go in before // its first call, while nothing of SDL's exists yet. sdl_allocator = gpa; if (!c.SDL_SetMemoryFunctions(sdl_heap.malloc, sdl_heap.calloc, sdl_heap.realloc, sdl_heap.free)) { log.err("SDL_SetMemoryFunctions: {s}", .{c.SDL_GetError()}); return error.SdlInit; } _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { log.err("SDL_Init: {s}", .{c.SDL_GetError()}); return error.SdlInit; } // SDL allocates from gpa (above), so what it still holds at exit is in a // Debug build's leak report. The window, the GPU device and everything // made on it go back first (the defers below run before this one). What // is left is SDL 3.4.4's: its Vulkan backend never frees two 32-byte // arrays per command buffer (VULKAN_INTERNAL_DestroyCommandPool; SDL's // main branch frees them). defer c.SDL_Quit(); var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; // A test run renders to the capture texture; keep it off the desktop. if (test_mode) win_flags |= c.SDL_WINDOW_HIDDEN; const native_wayland = !test_mode and std.mem.eql(u8, std.mem.span(c.SDL_GetCurrentVideoDriver()), "wayland"); // Background-only opacity needs per-pixel alpha; whole-window compositor // opacity would dim the text as well. SDL requires this flag at creation. const surface_opacity = config.gui_transparent or native_wayland; if (surface_opacity) win_flags |= c.SDL_WINDOW_TRANSPARENT; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; defer c.SDL_DestroyWindow(window); updateDisplayScale(window); if (native_wayland) log.info("Wayland opacity: background-only transparent surface", .{}); const arrow_cursor = c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1); defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor); // Its hot spot the middle, as acme's {-7, -7} offset puts it. const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7); defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor); if (arrow_cursor) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; defer c.SDL_DestroyGPUDevice(device); var soft_present = false; var soft_renderer: ?*c.SDL_Renderer = null; const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| !(std.mem.eql(u8, raw, "0") or raw.len == 0) else false; if (surface_opacity or force_soft or !c.SDL_ClaimWindowForGPUDevice(device, window)) { if (surface_opacity) log.info("transparent window: the GPU API will not claim one, presenting through SDL_Renderer", .{}) else if (force_soft) log.info("PARDES_SOFT_PRESENT set: presenting in software", .{}) else log.warn("ClaimWindowForGPUDevice: {s}; presenting in software", .{c.SDL_GetError()}); soft_renderer = c.SDL_CreateRenderer(window, null) orelse { log.err("SDL_CreateRenderer: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; soft_present = true; } const present_mode: c.SDL_GPUPresentMode = blk: { if (soft_present) break :blk c.SDL_GPU_PRESENTMODE_VSYNC; if (env.get("PARDES_SDL_PRESENT")) |raw| { const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) c.SDL_GPU_PRESENTMODE_IMMEDIATE else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) c.SDL_GPU_PRESENTMODE_MAILBOX else if (std.ascii.eqlIgnoreCase(raw, "vsync")) c.SDL_GPU_PRESENTMODE_VSYNC else null; if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } break :blk c.SDL_GPU_PRESENTMODE_VSYNC; }; if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); if (!soft_present) { _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); } const swapchain_format = if (soft_present) softTargetFormat(device) else c.SDL_GetGPUSwapchainTextureFormat(device, window); font_allocator = gpa; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; }; // HarfBuzz's process-wide objects are made once and never freed: made // from the default heap, they stay out of a Debug build's leak report. font_allocator = std.heap.smp_allocator; c.ui_font_prime(font); font_allocator = gpa; // A harness forcing a density (PARDES_TEST_SCALE) draws the text as a // person on that display would size it, so its chrome is seen at the // proportion it has there; PARDES_TEST_FONT_SCALE sizes the text alone // (a density's text with 1x chrome: how it looked before dp). A live // window keeps the size the user sets. const font_scale: f32 = if (env.get("PARDES_TEST_FONT_SCALE")) |v| std.fmt.parseFloat(f32, v) catch display_scale else display_scale; const px: f32 = if (test_mode) 27.0 * std.math.clamp(font_scale, 1, 4) else 27.0; var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(font, px, &cw, &chh, &asc); const cell_w: u32 = @intCast(@max(cw, 1)); const cell_h: u32 = @intCast(@max(chh, 1)); const tagline = taglineRaster(font, px, cell_w, cell_h, config.gui_tagline_font_percent); if (test_mode) { const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; // PARDES_TEST_PAD: extra pixels past the grid, as a real window has. const pad = envU16(env, "PARDES_TEST_PAD") orelse 0; _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w + pad), @intCast(rows * cell_h + pad)); _ = c.SDL_SyncWindow(window); } var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = atlas_w; tex_info.height = atlas_h; tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); samp_info.min_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mag_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mipmap_mode = c.SDL_GPU_FILTER_NEAREST; samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; const linear_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); const pipeline_over = try makePipeline(device, swapchain_format, frag_spv, 1, premultipliedBlend()); const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend()); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(device, swapchain_format); const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; const image_buffer_size: u32 = initial_image_capacity * @sizeOf(ImageInstance); var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 }; const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate; var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 }; const image_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ivx_info) orelse return error.GpuCreate; const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); defer gpa.free(atlas_stage); @memset(atlas_stage, 0); const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); defer gpa.free(overlay_vertices); var g: Gui = .{ .window = window, .arrow_cursor = arrow_cursor, .link_cursor = link_cursor, .box_cursor = box_cursor, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, .pipeline_over = pipeline_over, .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, .atlas_xfer = atlas_xfer, .overlay_vbuf = overlay_vbuf, .overlay_vxfer = overlay_vxfer, .overlay_vertices = overlay_vertices, .image_vbuf = image_vbuf, .image_vxfer = image_vxfer, .image_capacity = initial_image_capacity, .font = font, .px = px, .tagline_px = tagline.px, .tagline_percent = config.gui_tagline_font_percent, .tagline_width = tagline.width, .tagline_height = tagline.height, .cell_w = cell_w, .cell_h = cell_h, .ascent = asc, .tagline_baseline = tagline.baseline, .atlas_stage = atlas_stage, .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0, .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null, .capture_dir = capture_dir orelse "", .soft_present = soft_present, .transparent = config.gui_transparent, .surface_opacity = surface_opacity, .soft_renderer = soft_renderer, }; // The pipelines, textures and buffers the Gui holds, whatever they were // regrown to; the image ones are released below. defer { c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline_over); c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); c.SDL_ReleaseGPUSampler(device, g.atlas_sampler); c.SDL_ReleaseGPUSampler(device, g.linear_sampler); c.SDL_ReleaseGPUTransferBuffer(device, g.atlas_xfer); c.SDL_ReleaseGPUBuffer(device, g.overlay_vbuf); c.SDL_ReleaseGPUTransferBuffer(device, g.overlay_vxfer); if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(device, buffer); if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(device, transfer); if (g.scene_tex) |texture| c.SDL_ReleaseGPUTexture(device, texture); if (g.capture_tex) |texture| c.SDL_ReleaseGPUTexture(device, texture); if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(device, transfer); if (g.gamepad) |pad| c.SDL_CloseGamepad(pad); } setGuiFontName(&g, "Adwaita Mono"); loadFallbackFonts(&g, gpa); defer deinitFallbackFonts(&g, gpa); defer g.glyphs.deinit(); defer g.words.deinit(gpa); defer { clearNativeImages(&g); g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); g.cover.deinit(gpa); g.decor.deinit(gpa); g.post.deinit(gpa, device); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline); defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll defer g.glide.deinit(gpa); resetGlyphAtlas(&g); if (attach) |requested| return attachRequested(gpa, &g, requested); var attached: ?detached_client.Client = null; try localSession(init, &g, opts_in, test_mode, &attached); if (attached) |*client| return attachedLoop(gpa, &g, client); } fn localSession( init: std.process.Init, g: *Gui, opts_in: pardes.Options, test_mode: bool, attached: *?detached_client.Client, ) !void { const io = init.io; const gpa = init.gpa; const allocs = pardes.memory.init(gpa); defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); // stb_image allocator for image panes if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } var opts = opts_in; opts.image_allocator = allocs.image; opts.pdf_allocator = allocs.pdf; opts.tree_sitter_allocator = allocs.tree_sitter; opts.frame_allocator = allocs.frame; var pw: c_int = 0; var ph: c_int = 0; if (opts.load_path != null) { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); } var core = if (opts.load_path) |lp| blk: { const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.dump.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); core.native_images = true; core.native_image_patches = true; observeGuiFont(g, core); syncTaglineFont(g, core); syncLigatures(g, core); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = allocs.lsp, .lsp_workers = &lsp_workers, .sdl_wake = true, }; defer { lsp_workers.wait(); queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); var inotify_fd: c_int = file_watch.init(true); var watch_reader: ?std.Thread = null; var watch_stop: [2]c_int = .{ -1, -1 }; defer if (inotify_fd >= 0) { if (watch_reader) |thread| { _ = host_io.writeFd(watch_stop[1], "x"); thread.join(); } for (watch_stop) |fd| if (fd >= 0) { _ = libc.close(fd); }; file_watch.stop(inotify_fd); _ = libc.close(inotify_fd); inotify_fd = -1; }; var watches: file_watch.Table = @splat(null); var fs = ninep_io.start(io, gpa, core); defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, .gui = g, .io = io, .gpa = gpa, .lsp_allocator = allocs.lsp, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = inotify_fd, .watches = &watches, .fs = fs, .test_mode = test_mode, .test_clock = if (test_mode) host_io.testClock() else null, .test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60, }; defer shell.shutdownPtys(); // A virtual clock never blinks: goldens and feel series hold still. g.no_blink = shell.test_clock != null; const host = shell.host(); core.host = host; while (core.nextEffect()) |e| core.perform(e); for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); if (inotify_fd >= 0) { watch_stop = try stopPipe(); watch_reader = try std.Thread.spawn(.{}, watchThread, .{ inotify_fd, watch_stop[0], &queue }); } if (fs) |f| try f.watch(&queue, wakeFs); _ = c.SDL_StartTextInput(g.window); if (test_mode) setStdinRaw() catch {}; shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); host_io.setExitWake(&queue, wakeForExit); defer host_io.setExitWake(null, null); while (!core.quit) { pardes.turn.restoreSettled(); try core.pump(host); if (core.quit) break; // a session that ended does not restore into one if (core.takeAttach()) |req| { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows); switch (outcome) { .greeted => |client| { attached.* = client; break; }, else => { var mbuf: [256]u8 = undefined; core.setMessage(req.pane, attachFailure(&mbuf, outcome, req.name)); }, } } if (core.takeRestore()) |rp| blk: { const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; defer gpa.free(bytes); const nc = dump.restore(core, bytes, rp) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; pipe_tasks.cancelAll(io); queue.discardCompletions(); shell.stopPtys(); for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( inotify_fd, &watches, @intCast(wid), null, 0, .{ .text = 0 }, ); _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false); clearNativeImages(g); g.presented_images.clearRetainingCapacity(); g.prepared_images.clearRetainingCapacity(); nc.native_images = true; nc.native_image_patches = true; nc.host = host; if (fs) |f| f.reset(nc); core.deinit(); core = nc; shell.core = nc; shell.surface = null; observeGuiFont(g, core); syncTaglineFont(g, core); syncLigatures(g, core); } } if (fs) |f| { f.deinit(gpa); fs = null; shell.fs = null; } } fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 { return switch (outcome) { .greeted => unreachable, .no_session => if (requested.len != 0) std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch "Attach: no detached session under that name" else "Attach: nothing is detached (start one with `pardes --detach`)", .ambiguous => |n| std.fmt.bufPrint(buf, "Attach: {d} detached sessions; say which one", .{n}) catch "Attach: several detached sessions; say which one", .refused => |why| switch (why) { .version => "Attach: that session speaks a different wire version — it is another build of pardes", .full => "Attach: that session already has every frontend slot taken", .quitting => "Attach: that session is ending", }, .silent => "Attach: that session took the connection and never greeted us", .lost => |err| std.fmt.bufPrint(buf, "Attach: lost the connection ({t})", .{err}) catch "Attach: lost the connection", }; } fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows); switch (outcome) { .greeted => |greeted| { var client = greeted; return attachedLoop(gpa, g, &client); }, else => { var mbuf: [256]u8 = undefined; log.err("{s}", .{attachFailure(&mbuf, outcome, requested)}); return switch (outcome) { .no_session => error.NoSession, .ambiguous => error.AmbiguousSession, .refused => error.Refused, .silent => error.NoGreeting, .lost => |err| err, .greeted => unreachable, }; }, } } fn attachedResize(client: *detached_client.Client, g: *const Gui, cols: u16, rows: u16) !void { try client.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = @intCast(g.cell_w), .body_h = @intCast(g.cell_h), .tagline_w = @intCast(g.tagline_width), .tagline_h = @intCast(g.tagline_height), } } } }); } fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void { defer client.detach(); _ = c.SDL_StartTextInput(g.window); var in: Input = .{ .client = client }; g.attached_layers = &client.body_layers; g.attached_tag_layers = &client.tag_layers; var dirty = false; var geom = windowCells(g); try attachedResize(client, g, geom.cols, geom.rows); while (true) { const link = client.wait(detached_client.poll_ms); while (true) { const msg = (try client.next()) orelse break; switch (msg) { .welcome => {}, .refuse => |why| { log.err("session refused an already-greeted frontend: {t}", .{why}); return error.Refused; }, .frame => dirty = true, .quit => return, .detach => return, .set_clipboard => |text| putClipboard(gpa, text), .read_clipboard => if (takeClipboard()) |text| { defer c.SDL_free(text.ptr); in.post(.{ .paste = text }); }, .open_link => |url| look.openLink(url), } } try link; var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); pollGamepad(g, &in); if (in.lost) |err| return err; if (in.quit) return; const now = windowCells(g); if (now.cols != geom.cols or now.rows != geom.rows) { geom = now; try attachedResize(client, g, now.cols, now.rows); } if (!dirty) continue; dirty = false; paintAttached(g, gpa, client); } } fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { var surface = attachedSurface(client); _ = renderFrame( g, gpa, null, &surface, false, ) catch |err| blk: { log.err("render: {t}", .{err}); break :blk false; }; } /// The session's frame as a surface: its cells, layers, regions and palette, /// so its chrome is drawn from them as a local one's is. fn attachedSurface(client: *const detached_client.Client) pardes.Surface { var surface: pardes.Surface = .{ .cols = client.cols, .rows = client.rows, .cells = client.grid.items, .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, .pointer_shape = client.pointer_shape, .body_layers = client.body_layers, .tag_layers = client.tag_layers, .chrome = client.chrome, }; const placed = client.regions.items[0..@min(client.regions.items.len, surface.regions.len)]; @memcpy(surface.regions[0..placed.len], placed); surface.nregions = @intCast(placed.len); return surface; } test "an attached frontend draws the session's chrome from its frame alone" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); // A gui-shaped core, whose tags and notices are layers. core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); core.settings.focus_tint = true; try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice with a rule under it")); pardes.test_api.sync(core); // Past its slide into place, where its rule shows. for (0..30) |_| core.update(.tick); const s = try core.render(core.scratch.allocator()); const wire = @import("../detached/wire.zig"); var client: detached_client.Client = .{ .gpa = gpa }; defer client.deinit(); var welcome: [64]u8 = undefined; try client.in.appendSlice(gpa, try wire.encodeServer(&welcome, .{ .welcome = .{ .slot = 0, .cols = s.cols, .rows = s.rows } })); const out = try gpa.alloc(u8, wire.frameBound(s.cols, s.rows) + wire.layersBound(&s.body_layers, &s.tag_layers, s.regionList())); defer gpa.free(out); try client.in.appendSlice(gpa, try wire.encodeFrameLayers(out, s.cols, s.rows, null, s.pointer_shape, s.cells, &.{}, &s.body_layers, &s.tag_layers, s.regionList(), s.chrome)); _ = try client.next(); _ = try client.next(); const attached = attachedSurface(&client); var g: Gui = undefined; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; g.decor = .empty; defer g.decor.deinit(gpa); var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); const local = try gpa.dupe(CellInstance, g.decor.items); defer gpa.free(local); try std.testing.expect(local.len > 8); try std.testing.expect(groups.items[groups.notices].decor_count != 0); try buildDecor(&g, gpa, &attached, &groups, fixedCellLayout(&g), 800, 480, true); try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(local), std.mem.sliceAsBytes(g.decor.items)); } fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; const allocs = pardes.memory.init(gpa); defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; var opts = opts_in; opts.image_allocator = allocs.image; opts.pdf_allocator = allocs.pdf; opts.tree_sitter_allocator = allocs.tree_sitter; opts.frame_allocator = allocs.frame; if (opts.load_path != null) { opts.cols = grid_cols; opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.dump.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = allocs.lsp, .lsp_workers = &lsp_workers, .sdl_wake = false, }; defer { lsp_workers.wait(); queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); var watches: file_watch.Table = @splat(null); const fs = ninep_io.start(io, gpa, core); defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, .io = io, .gpa = gpa, .lsp_allocator = allocs.lsp, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = -1, .watches = &watches, .fs = fs, }; defer shell.shutdownPtys(); const host = shell.host(); core.host = host; while (core.nextEffect()) |e| core.perform(e); for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); host_io.setExitWake(&queue, wakeForExit); defer host_io.setExitWake(null, null); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort { var first: std.heap.ArenaAllocator = .init(allocs.frame); defer first.deinit(); const surface = try core.render(first.allocator()); try dumpGrid(gpa, surface); core.acknowledgePanelPresentation(&.{}); } while (!core.quit) { var in: Input = .{ .core = core }; const r = try shell.feed.pump(gpa, &in, null); if (r.eof) break; if (r.n_events != 0) shell.saw_event = true; shell.drainQueue(); try core.pump(host); if (shell.dump_err) |err| return err; } if (core.quit) { gridPollFrame(&shell); var last: std.heap.ArenaAllocator = .init(allocs.frame); defer last.deinit(); gridPresent(&shell, try core.render(last.allocator())); gridPostPresent(&shell); if (shell.dump_err) |err| return err; } } const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, buf: [1024]u8 = undefined, fill: usize = 0, last_cols: u16 = 0, last_rows: u16 = 0, const Result = struct { eof: bool = false, n_events: usize = 0 }; fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result { var out: Result = .{}; var ws: posix.winsize = std.mem.zeroes(posix.winsize); if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and (ws.col != f.last_cols or ws.row != f.last_rows)) { f.last_cols = ws.col; f.last_rows = ws.row; f.applyResize(in, g, ws.col, ws.row); out.n_events += 1; } var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; pardes.turn.rest(); const polled = posix.poll(&fds, 16); pardes.turn.wake(); _ = polled catch return out; if ((fds[0].revents & posix.POLL.IN) == 0) { if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; return out; } const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); if (rn < 0) return out; if (rn > 0) latencyNote("R {d}\n", .{monoNs()}); if (rn == 0) { out.eof = true; return out; } const len = f.fill + @as(usize, @intCast(rn)); f.fill = 0; var seq_start: usize = 0; while (seq_start < len) { // ESC]777;mouse;;;;BEL // ESC]777;mouse;wheel;;;BEL // A mouse event at window pixels, built as SDL would and run // through dispatch, so a driver exercises the real pointer path // (pixel to cell, tag and body hits). SGR mouse carries only cells. const mouse_prefix = "\x1b]777;mouse;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], mouse_prefix)) { const body = f.buf[seq_start + mouse_prefix.len .. len]; const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; }; if (g) |gp| { var parts = std.mem.splitScalar(u8, body[0..end], ';'); const kind = parts.next() orelse ""; const second = parts.next() orelse ""; const button = std.fmt.parseInt(u8, second, 10) catch 0; const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; var sev = std.mem.zeroes(c.SDL_Event); if (std.mem.eql(u8, kind, "wheel")) { // ESC]777;mouse;wheel;;;BEL: // a whole number is a mouse's notches, else a touchpad's. const amount = std.fmt.parseFloat(f32, second) catch 0; sev.type = c.SDL_EVENT_MOUSE_WHEEL; sev.wheel.y = amount; sev.wheel.integer_y = @intFromFloat(@trunc(amount)); sev.wheel.mouse_x = x; sev.wheel.mouse_y = y; } else if (std.mem.eql(u8, kind, "motion")) { sev.type = c.SDL_EVENT_MOUSE_MOTION; sev.motion.x = x; sev.motion.y = y; sev.motion.state = if (button == 0 or button > 5) 0 else @as(u32, 1) << @intCast(button - 1); } else { sev.type = if (std.mem.eql(u8, kind, "down")) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; sev.button.button = button; sev.button.down = std.mem.eql(u8, kind, "down"); sev.button.x = x; sev.button.y = y; } dispatch(gp, in, &sev); out.n_events += 1; } seq_start += mouse_prefix.len + end + 1; continue; } const prefix = "\x1b]777;finger;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { const body = f.buf[seq_start + prefix.len .. len]; const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; }; if (parseFinger(body[0..end])) |finger| { if (g) |gp| { var wpw: c_int = 0; var wph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); const win_w: f32 = @floatFromInt(@max(wpw, 1)); const win_h: f32 = @floatFromInt(@max(wph, 1)); handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); } else if (in.core) |core| { handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1); } out.n_events += 1; } seq_start += prefix.len + end + 1; continue; } if ((len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) or (len - seq_start < mouse_prefix.len and std.mem.startsWith(u8, mouse_prefix, f.buf[seq_start..len]))) { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; if (result.n == 0) { // partial sequence: shift to front, read more std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } seq_start += result.n; const event = result.event orelse continue; switch (event) { .key_press => |key| { var text: []const u8 = ""; if (key.text) |t| text = f.cache.put(t); in.post(.{ .key = .{ .cp = mapKey(effCp(key)), .text = text, .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }); out.n_events += 1; }, .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, .wheel_right => .wheel_right, .none => .none, else => null, }; if (button) |b| { in.post(.{ .mouse = .{ .button = b, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(@max(m.col, 0)), .row = @intCast(@max(m.row, 0)), .ctrl = m.mods.ctrl, } }); out.n_events += 1; } }, .winsize => |vw| { f.applyResize(in, g, @intCast(vw.cols), @intCast(vw.rows)); out.n_events += 1; }, .paste => |text| { in.post(.{ .paste = text }); gpa.free(@constCast(text)); out.n_events += 1; }, else => {}, } } return out; } fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void { _ = f; if (g) |gp| { _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); _ = c.SDL_SyncWindow(gp.window); } else { in.post(.{ .resize = .{ .cols = cols, .rows = rows } }); } } }; var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off fn parseFinger(payload: []const u8) ?Finger { var parts = std.mem.splitScalar(u8, payload, ';'); const type_name = parts.next() orelse return null; const id_s = parts.next() orelse return null; const x_s = parts.next() orelse return null; const y_s = parts.next() orelse return null; const pressure_s = parts.next() orelse return null; if (parts.next() != null) return null; const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) .down else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) .motion else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) .up else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) .cancel else return null; return .{ .kind = kind, .id = std.fmt.parseInt(u64, id_s, 10) catch return null, .x = std.fmt.parseFloat(f32, x_s) catch return null, .y = std.fmt.parseFloat(f32, y_s) catch return null, .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, }; } fn setStdinRaw() !void { var term = try posix.tcgetattr(0); term.iflag.BRKINT = false; term.iflag.ICRNL = false; term.iflag.INPCK = false; term.iflag.ISTRIP = false; term.iflag.IXON = false; term.oflag.OPOST = false; term.lflag.ECHO = false; term.lflag.ICANON = false; term.lflag.IEXTEN = false; term.lflag.ISIG = false; term.cc[@intFromEnum(posix.V.MIN)] = 1; term.cc[@intFromEnum(posix.V.TIME)] = 0; try posix.tcsetattr(0, .NOW, term); } fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { if (surface.cols == 0 or surface.rows == 0) return; const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; var hdr: [128]u8 = undefined; const header = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, }) catch return; const footer = "---ENDFRAME---\n"; var total = try std.math.add(usize, header.len, footer.len); total = try std.math.add(usize, total, surface.rows); var y: u16 = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { const grapheme = surface.at(x, y).grapheme(); total = try std.math.add(usize, total, if (grapheme.len > 0) grapheme.len else 1); } } const frame = try gpa.alloc(u8, total); defer gpa.free(frame); var at: usize = 0; @memcpy(frame[at..][0..header.len], header); at += header.len; y = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { const grapheme = surface.at(x, y).grapheme(); if (grapheme.len > 0) { @memcpy(frame[at..][0..grapheme.len], grapheme); at += grapheme.len; } else { frame[at] = ' '; at += 1; } } frame[at] = '\n'; at += 1; } @memcpy(frame[at..][0..footer.len], footer); at += footer.len; std.debug.assert(at == frame.len); _ = host_io.writeFd(1, frame); } const Input = struct { core: ?*pardes.Pardes = null, client: ?*detached_client.Client = null, quit: bool = false, lost: ?anyerror = null, fn post(in: *Input, ev: pardes.Event) void { if (in.core) |core| return core.update(ev); const client = in.client orelse return; if (in.lost != null) return; client.send(.{ .event = ev }) catch |err| switch (err) { error.Overlong, error.NoSpace => {}, else => in.lost = err, }; } fn close(in: *Input) void { if (in.core) |core| core.quit = true; in.quit = true; } }; fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { // What the post chain sees of the pointer and the focus. switch (sev.type) { c.SDL_EVENT_MOUSE_MOTION => { const at = windowPointToPixels(windowGeometry(g.window), sev.motion.x, sev.motion.y); g.post.mouse(at.x, at.y, null); }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y); g.post.mouse(at.x, at.y, sev.button.down); }, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => { updateDisplayScale(g.window); // Underlines are drawn into the glyphs a logical pixel thick. resetGlyphAtlas(g); if (in.core) |core| core.needs_frame = true; }, c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true), c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false), else => {}, } switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(), c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { g.pointer_present = false; g.pointer_mapped = false; in.post(.pointer_leave); }, c.SDL_EVENT_KEY_DOWN => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; const step: f32 = if (!g.live_ctrl) 0 else switch (sev.key.key) { c.SDLK_EQUALS, c.SDLK_PLUS, c.SDLK_KP_PLUS => font_px_step, c.SDLK_MINUS, c.SDLK_UNDERSCORE, c.SDLK_KP_MINUS => -font_px_step, else => 0, }; if (step != 0) { const want = std.math.clamp(g.px + step, font_px_min, font_px_max); if (want != g.px) { g.px = want; refitFont(g, in.core); if (in.core) |core| observeGuiFont(g, core); } return; } keyDown(in, sev.key.key, sev.key.mod); }, c.SDL_EVENT_KEY_UP => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; }, c.SDL_EVENT_TEXT_INPUT => { const tptr = sev.text.text orelse return; var tlen: usize = 0; while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; const view = std.unicode.Utf8View.init(text) catch return; var it = view.iterator(); var at: usize = 0; while (it.nextCodepoint()) |cp| : (at = it.i) { in.post(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } }); } }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const b = sev.button; const button: pardes.Mouse.Button = switch (b.button) { 1 => .left, 2 => .middle, 3 => .right, 4 => .back, 5 => .forward, else => return, }; g.pad_x = b.x; g.pad_y = b.y; g.pointer_present = true; const mc = mouseCell(g, in.core, b.x, b.y) orelse { g.pointer_mapped = false; if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = last.col, .row = last.row, .tag_hit = last.tag_hit, .body_hit = last.body_hit, } }); in.post(.pointer_leave); return; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = button, .kind = if (b.down) .press else .release, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .rule_above = mc.rule_above, .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; g.pad_x = m.x; g.pad_y = m.y; g.pointer_present = true; const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) .left else if ((m.state & c.SDL_BUTTON_MMASK) != 0) .middle else if ((m.state & c.SDL_BUTTON_RMASK) != 0) .right else null; const mc = mouseCell(g, in.core, m.x, m.y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); return; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; if (w.y == 0 and w.x == 0) return; g.pad_x = w.mouse_x; g.pad_y = w.mouse_y; g.pointer_present = true; const mc = mouseCell(g, in.core, w.mouse_x, w.mouse_y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); return; }; g.pointer_mapped = true; g.pointer_cell = mc; if (w.y != 0 and std.math.isFinite(w.y)) { if (in.core) |core| { const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { resetScroll(g); }; if (hit) |id| { const pdf_target = if (comptime pardes.pdf_enabled) core.native_images and core.panes[id].?.pdfPage() != null else false; if (pdf_target) { resetScroll(g); in.post(.{ .pdf_scroll = .{ .pane = @intCast(id), .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), } }); } else { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; // A mouse wheel's notch is a whole line and // glides (G6); a touchpad's fraction moves the // picture by itself, under the finger. if (notchOf(w.y, w.integer_y)) |lines| { g.scroll_notch +|= lines; } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); } } } else { g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); while (g.scroll_delta <= -1) : (g.scroll_delta += 1) in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); } } if (w.x != 0) in.post(.{ .mouse = .{ .button = if (w.x > 0) .wheel_right else .wheel_left, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; const finger: Finger = .{ .kind = switch (sev.type) { c.SDL_EVENT_FINGER_DOWN => .down, c.SDL_EVENT_FINGER_MOTION => .motion, c.SDL_EVENT_FINGER_UP => .up, else => .cancel, }, .id = @intCast(f.fingerID), .x = f.x, .y = f.y, .pressure = f.pressure, }; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const win_w: f32 = @floatFromInt(@max(pw, 1)); const win_h: f32 = @floatFromInt(@max(ph, 1)); const fcw: f32 = @floatFromInt(g.cell_w); const fch: f32 = @floatFromInt(g.cell_h); handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), c.SDL_EVENT_GAMEPAD_ADDED => { if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); }, c.SDL_EVENT_GAMEPAD_REMOVED => { if (g.gamepad) |pad| { c.SDL_CloseGamepad(pad); g.gamepad = null; } }, c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP, c.SDL_EVENT_GAMEPAD_AXIS_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, => { const pad_input: deck.Input = switch (sev.type) { c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ .idx = sev.gbutton.button, .down = sev.gbutton.down, } }, c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ .idx = sev.gaxis.axis, .value = sev.gaxis.value, } }, else => .{ .touch = .{ .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), .phase = switch (sev.type) { c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, else => .motion, }, .x = sev.gtouchpad.x, .y = sev.gtouchpad.y, .pressure = sev.gtouchpad.pressure, } }, }; var acts: [3]deck.Action = undefined; for (acts[0..g.deck.feed(pad_input, &acts)]) |act| switch (act) { .move => |mv| { const geometry = windowGeometry(g.window); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); const dx = mv.dx * geometry.window_w / geometry.pixel_w; const dy = mv.dy * geometry.window_h / geometry.pixel_h; g.pad_x = std.math.clamp(g.pad_x + dx, 0, geometry.window_w - 1); g.pad_y = std.math.clamp(g.pad_y + dy, 0, geometry.window_h - 1); c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); g.pointer_present = true; const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { g.pointer_mapped = false; in.post(.pointer_leave); continue; }; g.pointer_mapped = true; g.pointer_cell = mc; if (old) |previous| if (mc.col == previous.col and mc.row == previous.row) continue; const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, .click => |ck| { const button: pardes.Mouse.Button = switch (ck.which) { .select => .left, .execute => .middle, .look => .right, }; g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = last.col, .row = last.row, .tag_hit = last.tag_hit, .body_hit = last.body_hit, } }); in.post(.pointer_leave); continue; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = button, .kind = if (ck.down) .press else .release, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, .wheel => |ticks| { g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); continue; }; g.pointer_mapped = true; g.pointer_cell = mc; var left = ticks; while (left != 0) { left += if (ticks > 0) -1 else 1; in.post(.{ .mouse = .{ .button = if (ticks > 0) .wheel_down else .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } }, .key => |k| in.post(.{ .key = switch (k) { .n => .{ .cp = 'n', .text = "n" }, .cap_n => .{ .cp = 'N', .text = "N" }, .enter => .{ .cp = pardes.Key.enter }, .tab => .{ .cp = pardes.Key.tab }, .tty_toggle => if (in.core) |core| .{ .cp = core.opts.tty_toggle, .ctrl = true } else .{ .cp = config.tty_toggle_alt[0].cp, .shift = true }, }, }), .rumble => if (g.gamepad) |pad| { _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); }, }; }, else => {}, } } fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; const shift = (mod & c.SDL_KMOD_SHIFT) != 0; const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; const cp: u21 = switch (sym) { c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, c.SDLK_BACKSPACE => pardes.Key.backspace, c.SDLK_TAB => pardes.Key.tab, c.SDLK_ESCAPE => pardes.Key.escape, c.SDLK_LEFT => pardes.Key.left, c.SDLK_RIGHT => pardes.Key.right, c.SDLK_UP => pardes.Key.up, c.SDLK_DOWN => pardes.Key.down, c.SDLK_HOME => pardes.Key.home, c.SDLK_END => pardes.Key.end, c.SDLK_PAGEUP => pardes.Key.page_up, c.SDLK_PAGEDOWN => pardes.Key.page_down, c.SDLK_DELETE => pardes.Key.delete, else => blk: { if (!(ctrl or alt or gui_mod)) break :blk 0; if (sym < 128 and sym >= ' ') break :blk @intCast(sym); break :blk 0; }, }; if (cp == 0) return; in.post(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); } fn pixelCell(px: f32, cell: u32) u16 { const idx = @floor(@max(px, 0) / @as(f32, @floatFromInt(@max(cell, 1)))); return @intFromFloat(@min(idx, 10_000)); } fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, ) MouseCell { const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; // On the rule over this row, which is where two panes meet if they do. point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < dp(rule_grab_px); if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| { for (p.surface.tagLayers()) |*layer| { if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { point.tag_hit = hit; break; } } }; return point; } fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { var point = gridCellAtDimensions(core, x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width)); if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @as(f32, @floatFromInt(g.cell_w)), y / @as(f32, @floatFromInt(g.cell_h)))) |mapped| for (p.surface.bodyLayers()) |*layer| { if (layer.bodyHit(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.body_hit = hit; break; } }; if (core == null) if (g.attached_layers) |layers| for (layers) |*layer| { if (layer.bodyHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { point.body_hit = body_hit; break; } }; if (core == null) if (g.attached_tag_layers) |layers| for (layers) |*layer| { if (layer.tagHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.tag_hit = hit; break; } }; return point; } test "tagline pointer mapping retains physical grips and carries logical text coordinates" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); core.update(.{ .command = "Newcol" }); core.presentation.enabled = false; core.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 12 }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); _ = try core.render(arena.allocator()); const r = core.rects[core.active]; const body_w: f32 = 10; const body_h: f32 = 20; const tag_w: f32 = 8; const tag_y = r.y; const x = @as(f32, @floatFromInt(r.x + pardes.TAG_TEXT_INSET)) * body_w + 3.5 * tag_w; const tag = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(pixelCell(x, 10), tag.col); try std.testing.expectEqual(tag_y, tag.row); try std.testing.expectEqual(@as(u16, 3), tag.tag_hit.?.col); try std.testing.expectEqual(pardes.TagKind.pane, tag.tag_hit.?.kind); const grip = gridCellAtDimensions(core, @as(f32, @floatFromInt(r.x + config.GUTTER)) * body_w - 0.1, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expect(grip.tag_hit == null); try std.testing.expectEqual(r.x + config.GUTTER - 1, grip.col); const body_y = tag_y + core.panes[core.active].?.tag_rows; // a long tag wraps const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(body_y)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(pixelCell(x, 10), body.col); try std.testing.expectEqual(body_y, body.row); try std.testing.expect(body.tag_hit == null); const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(@as(u16, 2), topbar.col); try std.testing.expectEqual(@as(u16, 3), topbar.tag_hit.?.col); try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind); } test "a click at the window's corner lands in the corner cell with a post chain on" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer core.deinit(); try std.testing.expect(core.settings.post.set(.crt, "", true)); var g: Gui = undefined; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; const geometry: WindowGeometry = .{ .window_w = 400, .window_h = 240, .pixel_w = 800, .pixel_h = 480 }; const last = mouseCellWithGeometry(&g, core, 399.9, 239.9, geometry).?; try std.testing.expectEqual(@as(u16, 79), last.col); try std.testing.expectEqual(@as(u16, 23), last.row); const first = mouseCellWithGeometry(&g, core, 0, 0, geometry).?; try std.testing.expectEqual(@as(u16, 0), first.col); try std.testing.expectEqual(@as(u16, 0), first.row); } /// Where a window point lands: the cell drawn under it. Input is identity: /// no post pass moves a pixel (the bundled Crt has no barrel), so a click /// is exactly where it looks. fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); return gridCellAtPixels(g, core, physical.x, physical.y); } fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window)); } fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button { const buttons = c.SDL_GetMouseState(null, null); if ((buttons & c.SDL_BUTTON_LMASK) != 0 or g.deck.r2_held or g.deck.rpad_click) return .left; if ((buttons & c.SDL_BUTTON_MMASK) != 0 or g.deck.l2_held) return .middle; if ((buttons & c.SDL_BUTTON_RMASK) != 0 or g.deck.look_held) return .right; return null; } fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { if (!g.pointer_present) return; const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse { if (g.pointer_mapped) core.update(.pointer_leave); g.pointer_mapped = false; return; }; const moved = !g.pointer_mapped or g.pointer_cell == null or g.pointer_cell.?.col != mc.col or g.pointer_cell.?.row != mc.row; g.pointer_mapped = true; g.pointer_cell = mc; if (!moved) return; const held = heldPointerButton(g); core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } fn pollGamepad(g: *Gui, in: *Input) void { const pad = g.gamepad orelse return; const geometry = windowGeometry(g.window); const deadzone: f32 = 8000; const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; const speed_x = speed * geometry.window_w / geometry.pixel_w; const speed_y = speed * geometry.window_h / geometry.pixel_h; const pointer_moved = @abs(ax) > deadzone or @abs(ay) > deadzone; if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed_x, 0, geometry.window_w - 1); if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed_y, 0, geometry.window_h - 1); if (pointer_moved) { g.pointer_present = true; c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); } const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { if (g.pointer_present and g.pointer_mapped) in.post(.pointer_leave); if (g.pointer_present) g.pointer_mapped = false; return; }; if (g.pointer_present) { g.pointer_mapped = true; g.pointer_cell = mc; if (old == null or mc.col != old.?.col or mc.row != old.?.row) { const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } } const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; while (@abs(g.pad_scroll) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); } while (@abs(g.pad_scroll_h) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); } } const Shell = struct { core: *pardes.Pardes, gui: ?*Gui = null, io: std.Io, gpa: std.mem.Allocator, fs: ?*ninep_io.Listener = null, lsp_allocator: std.mem.Allocator, prompt_rcs: *const host_io.Shell.PromptFiles, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, threads_ok: bool = false, test_mode: bool = false, feed: StdinFeed = .{}, presented: bool = false, skipped_presents: u8 = 0, surface: ?*pardes.Surface = null, saw_event: bool = false, dump_err: ?anyerror = null, retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. test_clock: ?u64 = null, /// The display rate a virtual clock stands in for (PARDES_TEST_HZ). test_hz: u64 = 60, fn reap(s: *Shell) void { const now = shellClock(); for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now); for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) { reapShell(&pt.pid, &pt.kill_at, now); if (pt.pid == 0) slot.* = null; }; } fn closePaneShell(ctx: ?*anyopaque, pane: u8) void { shellOf(ctx).closePty(pane); } fn closePty(s: *Shell, pane: u8) void { const pt = if (s.ptys[pane]) |*pt| pt else return; if (pt.fd < 0) return; s.queue.cancelReader(pane, s.gens[pane]); if (pt.reader) |thread| { _ = host_io.writeFd(pt.stop[1], "x"); thread.join(); pt.reader = null; for (pt.stop) |fd| _ = libc.close(fd); pt.stop = .{ -1, -1 }; } _ = libc.close(pt.fd); pt.fd = -1; const now = shellClock(); reapShell(&pt.pid, &pt.kill_at, now); if (pt.pid == 0) { s.ptys[pane] = null; return; } _ = libc.kill(pt.pid, libc.SIG.HUP); pt.kill_at = now + 100; for (&s.retired_shells) |*child| if (child.pid == 0) { child.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; s.ptys[pane] = null; return; }; // No room to wait for it here: a slot kept for it would refuse the // next shell spawned into this pane until it was reaped. host_io.retireShell(pt.pid); s.ptys[pane] = null; } fn stopPtys(s: *Shell) void { for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); for (s.gens) |*gen| gen.* +%= 1; s.reap(); } fn shutdownPtys(s: *Shell) void { s.stopPtys(); for (s.retired_shells) |child| if (child.pid > 0) { _ = libc.kill(child.pid, libc.SIG.KILL); }; for (s.ptys) |slot| if (slot) |pt| if (pt.pid > 0) { _ = libc.kill(pt.pid, libc.SIG.KILL); }; for (&s.retired_shells) |*child| if (child.pid > 0) { while (libc.waitpid(child.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} child.* = .{}; }; for (s.ptys) |*slot| if (slot.*) |pt| { while (libc.waitpid(pt.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} slot.* = null; }; } fn reconcilePtys(s: *Shell) void { s.reap(); for (s.ptys, 0..) |slot, pane| if (slot) |pt| { if (pt.fd < 0) continue; const current = s.core.panes[pane]; if (current == null or current.?.serial != pt.serial or !current.?.isTerminal()) s.closePty(@intCast(pane)); }; } fn host(s: *Shell) pardes.Host { return .{ .ctx = s, .vtable = if (s.gui == null) &grid_vtable else &vtable }; } const vtable: pardes.Host.VTable = .{ .wait_input = waitInput, .now = guiNow, .present = present, .post_present = postPresent, .poll_frame = pollFrame, .spawn = spawnPane, .pty_write = ptyWrite, .pty_resize = ptyResize, .pty_signal = ptySignal, .close_pty = closePaneShell, .tty_taken = ttyTaken, .kill_job = killJob, .write_file = writeFile, .write_dump = writeDump, .watch_file = watchFile, .watch_theme = watchTheme, .dump_themes = dumpThemes, .set_clipboard = setClipboard, .read_clipboard = readClipboard, .open_link = openLink, .lsp = lsp, .pipe = pipe, }; const grid_vtable: pardes.Host.VTable = vt: { var v = vtable; v.wait_input = null; v.now = null; v.poll_frame = gridPollFrame; v.present = gridPresent; v.post_present = gridPostPresent; v.set_clipboard = null; v.read_clipboard = null; break :vt v; }; /// The command panes' exits, told once their output is in (host_io). fn takeExits(s: *Shell) void { host_io.takeExits(s.core, s.ptys, s, closeWatched); } fn closeWatched(s: *Shell, id: usize) void { s.closePty(@intCast(id)); s.saw_event = true; } fn drainQueue(s: *Shell) void { s.reconcilePtys(); // After this batch's output, which an exit is told behind. defer s.takeExits(); var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { .output => |o| { if (s.gens[o.pane] == o.gen) s.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); s.gpa.free(o.bytes); s.saw_event = true; }, .eof => |e| { if (s.gens[e.pane] == e.gen) eof: { if (e.failure) |err| { s.closePty(e.pane); if (s.core.panes[e.pane]) |pane| { pane.body.mode = .normal; s.core.reportError(e.pane, "terminal reader", err); } break :eof; } const pt = if (s.ptys[e.pane]) |*pt| pt else { s.core.update(.{ .eof = .{ .pane = e.pane } }); break :eof; }; // A command's pty stays open until its child has exited: // closing it would hang up one that runs on without it. // Its exit, if it came first, is told now its output is in. if (pt.cmd.watched) { host_io.commandEof(s.core, s.ptys, e.pane, s, closeWatched); break :eof; } // Unwatched, a command's exit is read here, as its end, // and a shell's, for a run waiting on it and the log. const unwatched = if (s.core.panes[e.pane]) |pane| pane.command != null else false; const status = host_io.exitStatus(pt.pid, 100); if (status != null) pt.pid = 0; // reaped: no shell to retire s.closePty(e.pane); if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } }); s.core.update(.{ .eof = .{ .pane = e.pane } }); } s.saw_event = true; }, .lsp => |l| { s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); if (l.rows) |rows| s.lsp_allocator.free(rows); s.saw_event = true; }, .lsp_status => |text| { var mbuf: [256]u8 = undefined; s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_allocator.free(text); s.saw_event = true; }, .pipe => |response_value| { var response = response_value; s.core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, .failure = response.failure, } }); response.deinit(s.gpa); s.saw_event = true; s.pipe_tasks.finish(s.io, response_value.id); }, .files_changed => check_files = true, .fs_ready => {}, }; if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches)) s.queue.push(.files_changed); } }; fn wakeFs(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.push(.fs_ready); } fn shellOf(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = shellOf(ctx); const core = s.core; var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); // Idle is asleep until an event: SDL's own, and the user events the // shell's queue raises for a terminal's output, a completion, a file // system request. What the shell moves or times itself polls every // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, // a post chain that moves on its own (ShaderAnimation), // a retired shell waiting to be killed, a present to retry. A // minimized window sleeps through the core's animation too: nothing // shows it, and it is caught up at once when the window is back. const retiring = for (s.retired_shells) |child| { if (child.kill_at != 0) break true; } else for (s.ptys) |slot| { if (slot) |pt| if (pt.kill_at != 0) break true; } else false; const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.scroll_notch != 0 or g.glide.moving() or g.gamepad != null or s.test_mode or core.needs_frame or retiring or g.post.animating(core.settings.shader_animation); const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: // waiting here too would make every tick cost two frames. // A chain that moves on its own wakes once a display refresh. const poll_ms: c_int = if (g.post.animating(core.settings.shader_animation)) @intCast(@max(1, refreshNs(g) / std.time.ns_per_ms)) else 16; const waited: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1; // The cursor's blink is the shell's to time (Chrome.cursor_idle_ms): // a frame a loop through an edge, else a wake at the next one; none // while the window is minimized or unfocused. A virtual clock never // blinks, so goldens hold still. const blink = if (s.test_clock != null or minimized) null else blinkNow(g, if (s.surface) |frame| &frame.chrome else null); const blink_ms: ?c_int = if (blink) |b| (if (b.edge) 1 else if (b.next_ns) |next| @as(c_int, @intCast(@min(std.math.maxInt(c_int), next / std.time.ns_per_ms + 1))) else null) else null; const ms: c_int = if (blink_ms) |b| (if (waited < 0) b else @min(waited, b)) else waited; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); // A virtual clock: while a render-time animation moves, each loop // is a presented frame and moves it by one display frame // (PARDES_TEST_HZ), events or not, as a real display's present // would; otherwise a wait that ran out moves it exactly to the // core's wake. // Through an edge, or woken for the next: the shell redraws the last // frame with the new alpha, no core render (§5.5 level B). if (blink_ms != null and (blink.?.edge or !got)) g.blink_due = true; if (s.test_clock) |*virtual| { if (core.continuous()) { const display = virtual.* + std.time.ns_per_s / s.test_hz; virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display; } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); } if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on // each would repeat hover and selection work the next undoes. var motion: ?c.SDL_Event = null; while (true) { if (sev.type == c.SDL_EVENT_MOUSE_MOTION) { motion = sev; } else { if (motion) |*m| dispatch(g, &in, m); motion = null; dispatch(g, &in, &sev); } if (!c.SDL_PollEvent(&sev)) break; } if (motion) |*m| dispatch(g, &in, m); } } if (s.test_mode) { const r = s.feed.pump(s.gpa, &in, s.gui) catch { core.quit = true; return; }; if (r.eof) { core.quit = true; return; } } s.drainQueue(); if (s.gui) |g| pollGamepad(g, &in); } fn pollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); s.reconcilePtys(); const core = s.core; if (s.fs) |f| if (f.tick().pending) s.queue.push(.fs_ready); const g = s.gui orelse { if (core.settings.window_opacity_pending) { var unchanged_opacity: u8 = 100; finishWindowOpacity(core, &unchanged_opacity, if (core.settings.window_opacity == 100) null else "requires a native SDL window"); } return; }; syncWindowOpacity(g, core); syncTaglineFont(g, core); syncLigatures(g, core); if (core.takeFontRequest()) |path| blk: { const bytes = filesystem.readFile(s.gpa, path) catch { core.rejectFont(); break :blk; }; const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { log.err("ui_font_new failed: {s}", .{path}); s.gpa.free(bytes); core.rejectFont(); break :blk; }; c.ui_font_free(g.font); if (g.font_bytes.len != 0) s.gpa.free(g.font_bytes); g.font = nf; g.font_bytes = bytes; // FreeType borrows them for the face lifetime if (core.settings.font.requested_size_hundredths != 0) g.px = @as(f32, @floatFromInt(core.settings.font.requested_size_hundredths)) / 100; setGuiFontName(g, core.settings.font.requested_name.get()); refitFont(g, core); acknowledgeGuiFont(g, core); } pollCwds(core, s.ptys); const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); // Between the core's frames, a chain that moves on its own redraws // alone, once a refresh of the display the window is on (a millisecond // early is on time: the wait that paces it is in whole milliseconds). if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa); // A blink edge: the last frame again, the cursor's alpha moved on. if (g.blink_due and !core.needs_frame and s.presented) if (s.surface) |frame| { g.blink_due = false; blink_redraws += 1; s.presented = renderFrame(g, s.gpa, core, frame, core.settings.debug) catch false; }; } /// Frames drawn for a blink alone, with no core render (tests, tracing). var blink_redraws: u64 = 0; /// The blink now, from the frame's time since input and how long ago it /// arrived; null where nothing blinks (off, idle, an unfocused window). fn blinkNow(g: *const Gui, chrome: ?*const pardes.Chrome) ?pardes.animation.Blink { const frame = chrome orelse return null; if (g.no_blink or frame.cursor_glide & 2 == 0 or frame.cursor_idle_ms == std.math.maxInt(u32)) return null; if (c.SDL_GetWindowFlags(g.window) & c.SDL_WINDOW_INPUT_FOCUS == 0) return null; const since = @as(u64, frame.cursor_idle_ms) * std.time.ns_per_ms + (monoNs() -| g.frame_arrival_ns); // Relative to now: `next_ns` is how long until the next edge. const b = pardes.animation.Blink.at(since, 0); return .{ .alpha = b.alpha, .edge = b.edge, .next_ns = if (b.next_ns) |next| next -| since else null }; } /// One refresh of the display the window is on, in ns; a frame of the /// core's where the display does not say. fn refreshNs(g: *const Gui) u64 { const mode = c.SDL_GetCurrentDisplayMode(c.SDL_GetDisplayForWindow(g.window)) orelse return pardes.animation.frame_ns; if (!(mode.*.refresh_rate > 1)) return pardes.animation.frame_ns; return @intFromFloat(@as(f64, std.time.ns_per_s) / mode.*.refresh_rate); } fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void { core.settings.window_opacity_pending = false; if (failure) |reason| { // Do not claim a value the compositor refused; an explicit retry of // the same command will arm a new request. core.settings.window_opacity = applied.*; var buf: [256]u8 = undefined; core.setMessage(core.active, std.fmt.bufPrint(&buf, "WindowOpacity: {s}", .{reason[0..@min(reason.len, 240)]}) catch "WindowOpacity: compositor rejected the request"); } else { applied.* = core.settings.window_opacity; } } fn syncWindowOpacity(g: *Gui, core: *pardes.Pardes) void { if (!core.settings.window_opacity_pending) return; // Offscreen captures also support per-pixel alpha. Never approximate this // with SDL_SetWindowOpacity: that would make foreground text translucent. finishWindowOpacity(core, &g.applied_window_opacity, if (g.surface_opacity or g.capture or core.settings.window_opacity == 100) null else "background transparency requires a native Wayland or alpha-capable SDL window"); } test "background opacity acknowledges success and rolls back unsupported surfaces" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var applied: u8 = 100; try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 70")); try std.testing.expect(core.settings.window_opacity_pending); finishWindowOpacity(core, &applied, null); try std.testing.expectEqual(@as(u8, 70), applied); try std.testing.expect(!core.settings.window_opacity_pending); try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 40")); finishWindowOpacity(core, &applied, "background transparency requires an alpha-capable SDL window"); try std.testing.expectEqual(@as(u8, 70), applied); try std.testing.expectEqual(applied, core.settings.window_opacity); try std.testing.expect(!core.settings.window_opacity_pending); const pane = core.panes[0].?; try std.testing.expectEqualStrings("WindowOpacity: background transparency requires an alpha-capable SDL window", pane.msg[0..pane.msg_len]); try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 40")); try std.testing.expect(core.settings.window_opacity_pending); finishWindowOpacity(core, &applied, null); try std.testing.expectEqual(@as(u8, 40), applied); try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 100")); finishWindowOpacity(core, &applied, null); try std.testing.expectEqual(@as(u8, 100), applied); } fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); const core = s.core; const g = s.gui orelse return; frame_started_ns = monoNs(); const frame = @constCast(surface); s.surface = frame; g.frame_arrival_ns = monoNs(); const scene_requested = g.post.ready(); if (!scene_requested) g.scene_failures = 0; s.presented = renderFrame( g, s.gpa, core, frame, core.settings.debug, ) catch |err| blk: { log.err("render: {t}", .{err}); break :blk false; }; // A skipped swapchain image would otherwise hide this input's frame until // the next event. A few retries only: a minimized window never gets one. s.skipped_presents = if (s.presented) 0 else s.skipped_presents +| 1; if (!s.presented and s.skipped_presents <= 3) core.present_skipped = true; if (g.scene_target_failed) { g.scene_failures +|= 1; log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures}); if (g.scene_failures >= 3) { core.disableSceneEffects(); g.scene_failures = 0; } } else if (scene_requested and s.presented) g.scene_failures = 0; } fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; // No image (a minimized window) shows no tracks; time still passes and // the pump advances it all the same. if (!s.presented) return; const frame = s.surface orelse return; refreshPresentedPointer(g, s.core); s.core.acknowledgePanelPresentation(frame.panelTracks()); } fn guiNow(ctx: ?*anyopaque) u64 { return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); s.reconcilePtys(); if (s.fs) |f| { if (f.tick().count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); // Grid time is virtual: each poll moves it straight to the core's // next wake, a frame at a time while anything moves. if (s.core.nextWake()) |due| { s.core.advance(due); s.saw_event = true; } } fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); s.presented = s.saw_event; s.saw_event = false; if (!s.presented) return; dumpGrid(s.gpa, @constCast(surface)) catch |err| { s.dump_err = err; s.presented = false; }; } fn gridPostPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); if (!s.presented) return; s.core.acknowledgePanelPresentation(&.{}); } fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = shellOf(ctx); s.reap(); s.closePty(pane); if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy); s.gens[pane] +%= 1; const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); const command = s.core.panes[pane].?.command != null; const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } }; s.ptys[pane] = pt; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) spawnReader(s.gpa, &s.ptys[pane].?, pane, s.gens[pane], s.queue) catch |err| { s.closePty(pane); s.core.reportError(pane, "terminal reader", err); }; } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| if (pt.fd >= 0) { _ = host_io.writeFd(pt.fd, bytes); }; } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { if (pt.fd < 0) return; const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } } fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = shellOf(ctx); // A command whose exit is recorded is reaped: its group may be another's. if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig, pt.cmd.watched); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = shellOf(ctx); const pt = s.ptys[pane] orelse return false; if (pt.fd < 0) return false; return host_io.ttyTaken(pt.pid, pt.fd); } fn killJob(ctx: ?*anyopaque, pane: u8) bool { const s = shellOf(ctx); const pt = s.ptys[pane] orelse return false; if (pt.fd < 0) return false; return host_io.killJob(pt.pid, pt.fd); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, path, err); if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| { if (std.mem.eql(u8, f.path, path)) if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory); filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err); s.core.setLastDump(path); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = shellOf(ctx); if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, s.watches, pane, on, mode)) s.queue.push(.files_changed); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const s = shellOf(ctx); if (file_watch.applyThemeEffect( s.core, s.gpa, s.inotify_fd, s.watches, generation, on, s.threads_ok, )) s.queue.push(.files_changed); } fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = shellOf(ctx); const config_dir = s.core.opts.config_dir orelse return; const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; defer s.gpa.free(out_dir); var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void { const z = gpa.dupeZ(u8, text) catch return; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); } fn takeClipboard() ?[:0]u8 { const raw = c.SDL_GetClipboardText() orelse return null; const text = std.mem.span(raw); if (text.len == 0) { c.SDL_free(raw); return null; } return text; } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { putClipboard(shellOf(ctx).gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { const text = takeClipboard() orelse return; defer c.SDL_free(text.ptr); shellOf(ctx).core.update(.{ .paste = text }); } test "the headless grid host round-trips a yank back as a paste" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &lsp_workers, .sdl_wake = false, }; defer queue.deinit(); var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var shell: Shell = .{ .core = core, .gui = null, .io = undefined, .gpa = gpa, .lsp_allocator = gpa, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = -1, .watches = &watches, }; core.host = shell.host(); try std.testing.expect(core.host.vtable == &Shell.grid_vtable); const pane = core.panes[0].?; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); while (core.nextEffect()) |e| core.perform(e); const yanked = core.registers.text(core.gpa, '+') orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); const before = pane.file.?.content.len; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'p' } }); try std.testing.expect(core.clip_pending != null); while (core.nextEffect()) |e| core.perform(e); try std.testing.expect(core.clip_pending == null); try std.testing.expectEqual(before + yanked.len, pane.file.?.content.len); } fn openLink(ctx: ?*anyopaque, url: []const u8) void { _ = ctx; look.openLink(url); // desktop browser } fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = shellOf(ctx); if (s.threads_ok) return spawnLsp(s.core, s.queue, req); s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = shellOf(ctx); if (s.threads_ok) return spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); } fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { if (pt.fd < 0) continue; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } fn shellClock() i64 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); } fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void { if (pid.* <= 0) return; const result = libc.waitpid(pid.*, null, libc.W.NOHANG); if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { pid.* = 0; kill_at.* = 0; } else if (kill_at.* != 0 and now >= kill_at.*) { _ = libc.kill(pid.*, libc.SIG.KILL); kill_at.* = 0; } } fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; return if (std.math.isFinite(next)) next else pending; } fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { const limit: f32 = @floatFromInt(body_rows); return std.math.clamp(lag + delta, -limit, limit) - lag; } fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { var l = lag + delta; var rows: i32 = 0; while ((delta > 0 and l > 0) or (delta < 0 and l < 0)) { const down = l > 0; l += if (down) -1 else 1; rows += if (down) 1 else -1; } return .{ .lag = l, .rows = rows }; } test "fractional wheel delta moves the picture by the same fraction" { const delta = accumulateWheelDelta(0, -0.125); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 0.125), delta); try std.testing.expectEqual(@as(f32, 0.125), picture); try std.testing.expectEqual(@as(i32, 1), s.rows); try std.testing.expectEqual(@as(f32, -0.875), s.lag); } test "repeated fractional deltas cross exactly one core row" { var core_row: i32 = 0; var lag: f32 = 0; var crossed: u32 = 0; for (0..8) |_| { const s = applyScrollDelta(lag, 0.125); core_row += s.rows; crossed += @intCast(@abs(s.rows)); lag = s.lag; } try std.testing.expectEqual(@as(u32, 1), crossed); try std.testing.expectEqual(@as(i32, 1), core_row); try std.testing.expectEqual(@as(f32, 0), lag); } test "wheel magnitude is preserved without quantization" { const delta = accumulateWheelDelta(0, -2.75); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 2.75), delta); try std.testing.expectEqual(@as(f32, 2.75), picture); try std.testing.expectEqual(@as(i32, 3), s.rows); try std.testing.expectEqual(@as(f32, -0.25), s.lag); try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); const bounded = boundScrollDelta(0, 3.0e38, 24); const safe = applyScrollDelta(0, bounded); try std.testing.expectEqual(@as(f32, 24), bounded); try std.testing.expectEqual(@as(i32, 24), safe.rows); } test "fractional scroll reversals preserve signed distance" { var core_row: i32 = 0; const forward = applyScrollDelta(0, 0.375); core_row += forward.rows; try std.testing.expectEqual(@as(f32, 0.375), @as(f32, @floatFromInt(core_row)) + forward.lag); const reverse = applyScrollDelta(forward.lag, -0.125); core_row += reverse.rows; try std.testing.expectEqual(@as(i32, -1), reverse.rows); try std.testing.expectEqual(@as(f32, 0.25), @as(f32, @floatFromInt(core_row)) + reverse.lag); try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); } /// A mouse wheel's step as whole lines (down positive), or null for a /// touchpad's fraction, which follows the finger as it is. fn notchOf(raw_y: f32, integer_y: i32) ?i32 { if (!std.math.isFinite(raw_y) or integer_y == 0 or raw_y != @round(raw_y)) return null; return @intFromFloat(std.math.clamp(-raw_y, -64, 64)); } test "a wheel's notch is whole lines, a touchpad's fraction is not" { try std.testing.expectEqual(@as(?i32, 1), notchOf(-1, -1)); try std.testing.expectEqual(@as(?i32, -3), notchOf(3, 3)); try std.testing.expectEqual(@as(?i32, null), notchOf(-0.25, 0)); try std.testing.expectEqual(@as(?i32, null), notchOf(-1.5, -1)); try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1)); } /// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at /// once, so every key and click acts on where the text is, and the picture /// glides there on the Motion flavour's spring (critically damped: text /// never passes its mark). How far the rows moved is measured on the next /// frame against the last, a line wrapped over several rows or a terminal's /// output included, and the rows that left are kept to draw in the gap. const Glide = struct { spring: pardes.animation.Spring = .{}, /// The picture's offset from the core's rows now (rows; negative, the /// picture still shows what is above). offset: f32 = 0, now_ns: u64 = 0, pane: usize = 0, rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, body_y: u16 = 0, body_h: u16 = 0, /// A scroll made and not yet measured: its direction, and the body's /// rows before it. pending: bool = false, dir: i8 = 0, before: []pardes.Cell = &.{}, /// The rows past the body's top (`above`) and bottom (`below`), nearest /// first, `bw` cells each: what the gap shows while the picture lags. above: []pardes.Cell = &.{}, below: []pardes.Cell = &.{}, nabove: u16 = 0, nbelow: u16 = 0, /// The Motion flavour the last loop step read. motion: pardes.animation.Motion = .{}, fn moving(glide: *const Glide) bool { return !glide.spring.settled or glide.pending; } fn stop(glide: *Glide) void { glide.spring = .{}; glide.offset = 0; glide.pending = false; glide.nabove = 0; glide.nbelow = 0; } fn deinit(glide: *Glide, gpa: std.mem.Allocator) void { gpa.free(glide.before); gpa.free(glide.above); gpa.free(glide.below); } fn bw(glide: *const Glide) u16 { return glide.rect.w -| config.GUTTER; } /// The core's rows moved by `rows` (down positive) since the last /// frame: the picture now lags by as many, carrying its speed. fn kick(glide: *Glide, rows: f32, now_ns: u64, motion: pardes.animation.Motion) void { // Follows input: the flavour's pace, half again, never overshooting. var pace = motion; pace.omega = motion.omega * 1.5; glide.spring.carry(-rows, now_ns, pace); // Never more than a body's rows behind: a flick past that jumps. const limit: f32 = @floatFromInt(glide.body_h); const now = glide.spring.value(now_ns); if (@abs(now) > limit) glide.spring.carry(std.math.clamp(now, -limit, limit) - now, now_ns, pace); glide.offset = glide.spring.value(now_ns); } /// Finds how far the pane's body rows moved, `dir` rows at a time, by /// the rows of `surface` against `before`; keeps the rows that left. fn measure(glide: *Glide, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { glide.pending = false; const w = glide.bw(); const h = glide.body_h; const x0 = glide.rect.x + config.GUTTER; if (w == 0 or h == 0 or x0 + w > surface.cols or glide.body_y + h > surface.rows or glide.before.len < @as(usize, w) * h) return glide.stop(); const now = struct { fn row(s: *const pardes.Surface, x: u16, y: u16, n: u16) []const pardes.Cell { return s.cells[@as(usize, y) * s.cols + x ..][0..n]; } }; // The least shift under which the rows agree, but for a few: the // cursor's line and its number's weight move with the scroll. const shift: u16 = found: for (1..h) |k| { const kk: u16 = @intCast(k); const allowed = @max(2, (h - kk) / 8); var misses: usize = 0; for (0..h - kk) |r| { const rr: u16 = @intCast(r); const old_row = glide.before[@as(usize, if (glide.dir > 0) rr + kk else rr) * w ..][0..w]; const new_row = now.row(surface, x0, glide.body_y + if (glide.dir > 0) rr else rr + kk, w); if (!sameRow(old_row, new_row)) { misses += 1; if (misses > allowed) continue :found; } } // Too few rows left to tell: no glide. if (h - kk < 3) break :found 0; break :found kk; } else 0; if (shift == 0) return glide.stop(); // The rows that left: past the top going down, the bottom going up. if (!glide.ensure(gpa)) return glide.stop(); const keep = if (glide.dir > 0) &glide.above else &glide.below; const count = if (glide.dir > 0) &glide.nabove else &glide.nbelow; const kept = @min(count.*, h - shift); std.mem.copyBackwards(pardes.Cell, keep.*[@as(usize, shift) * w ..][0 .. @as(usize, kept) * w], keep.*[0 .. @as(usize, kept) * w]); for (0..shift) |i| { const src_row: usize = if (glide.dir > 0) shift - 1 - i else h - shift + i; @memcpy(keep.*[i * w ..][0..w], glide.before[src_row * w ..][0..w]); } count.* = kept + shift; // What came into view is no longer past the other edge. const other = if (glide.dir > 0) &glide.below else &glide.above; const other_n = if (glide.dir > 0) &glide.nbelow else &glide.nabove; const dropped = @min(other_n.*, shift); std.mem.copyForwards(pardes.Cell, other.*[0 .. @as(usize, other_n.* - dropped) * w], other.*[@as(usize, dropped) * w ..][0 .. @as(usize, other_n.* - dropped) * w]); other_n.* -= dropped; glide.kick(@as(f32, @floatFromInt(shift)) * @as(f32, @floatFromInt(glide.dir)), glide.now_ns, glide.motion); } fn ensure(glide: *Glide, gpa: std.mem.Allocator) bool { const n = @as(usize, glide.bw()) * glide.body_h; inline for (.{ &glide.above, &glide.below }) |buffer| if (buffer.len < n) { const grown = gpa.realloc(buffer.*, n) catch return false; buffer.* = grown; }; return true; } }; fn sameRow(a: []const pardes.Cell, b: []const pardes.Cell) bool { for (a, b) |x, y| { if (x.default != y.default) return false; if (x.default) continue; if (!std.mem.eql(u8, x.grapheme(), y.grapheme()) or !std.meta.eql(x.style, y.style)) return false; } return true; } /// Feeds a wheel's notches to the core, snapshotting the body first so the /// next frame can measure the move (Glide.measure); advances the glide and /// asks for frames while it moves. fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const glide = &g.glide; // The glide runs on the core's continuous path: frames every loop, and // a virtual clock carried on. defer core.shell_continuous = glide.moving(); glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { _ = glide.spring.step(core.now_ns); core.needs_frame = true; if (glide.spring.settled) { glide.offset = 0; glide.nabove = 0; glide.nbelow = 0; } } const notch = g.scroll_notch; if (notch == 0) return; g.scroll_notch = 0; const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse return resetScroll(g); const r = core.rects[id]; if (r.h <= pane.tag_rows or r.w <= config.GUTTER) return resetScroll(g); const body_y = core.bodyTop(pane, r); const body_h = r.h -| pane.tag_rows; // A new pane or place: what was kept belongs to another picture. if (glide.pane != id or !std.meta.eql(glide.rect, r) or glide.body_y != body_y or glide.body_h != body_h or glide.pending) glide.stop(); glide.pane = id; glide.rect = r; glide.body_y = body_y; glide.body_h = body_h; // The body as the last frame drew it, which the move is measured from. const s = &core.surface; const w = r.w - config.GUTTER; const x0 = r.x + config.GUTTER; const animate = !glide.motion.instant and x0 + w <= s.cols and body_y + body_h <= s.rows and s.cells.len != 0; if (animate) { const n = @as(usize, w) * body_h; if (glide.before.len < n) glide.before = gpa.realloc(glide.before, n) catch &.{}; if (glide.before.len >= n) { for (0..body_h) |row| @memcpy(glide.before[row * w ..][0..w], s.cells[(@as(usize, body_y) + row) * s.cols + x0 ..][0..w]); } } var left = notch; var moved = false; while (left != 0) { const down = left > 0; left += if (down) -1 else 1; const was = pane.scroll(); core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } }); if (pane.scroll() == was) break; moved = true; } if (!moved) return; core.needs_frame = true; if (animate and glide.before.len >= @as(usize, w) * body_h) { glide.pending = true; glide.dir = @intCast(std.math.sign(notch)); } } /// The glide's picture: the pane's body rows moved by the offset, and in /// the gap it opens the rows that left, as `emitScrollRows` draws a /// touchpad's fraction. fn emitGlideRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { const glide = &g.glide; for (surface.panelTracks()) |track| if (track.active() and track.pane == glide.pane) return 0; const r = glide.rect; const bh = glide.body_h; const bw = glide.bw(); const x0 = r.x + config.GUTTER; const y0 = glide.body_y; if (bw == 0 or bh == 0 or x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // The scissor the shifted rows are drawn in is the scroll's. g.scroll_rect = r; g.scroll_body_y = y0; g.scroll_body_h = bh; var shifted = layout; shifted.y_off -= glide.offset * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var n: u32 = 0; var row = y0; while (row < y0 + bh) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col = x0; while (col < x0 + bw) : (col += 1) { const sidx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page); n += 1; } } // The gap: rows past the top while the picture lags a move down, past // the bottom for one up; as many as it opens. const gap: u16 = @intFromFloat(@min(@ceil(@abs(glide.offset)), @as(f32, @floatFromInt(bh)))); const kept = if (glide.offset < 0) glide.nabove else glide.nbelow; const rows = if (glide.offset < 0) glide.above else glide.below; for (0..@min(gap, kept)) |i| { const cells = rows[i * bw ..][0..bw]; // Row -1 - i above the body, or bh + i below it, in grid rows. const at_row: i32 = if (glide.offset < 0) @as(i32, y0) - 1 - @as(i32, @intCast(i)) else @as(i32, y0) + bh + @as(i32, @intCast(i)); var at = shifted; // emitInstance takes an unsigned row: move the layout instead. at.y_off += @as(f32, @floatFromInt(at_row - @as(i32, y0))) * layout.h; for (0..bw) |c_i| { emitInstance(g, instances, base + n, x0 + @as(u16, @intCast(c_i)), y0, at, win_w, win_h, null, cellFontRole(&cells[c_i]), cells, c_i, null, false, false, page); n += 1; } } return n; } test "a notch scrolls the core at once and the picture glides after it, a wrapped line's rows at a time" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); // Lines of two rows each at this width, numbered so no two rows match. var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); for (0..60) |i| { try text.print(gpa, "{d:0>4} ", .{i}); try text.appendNTimes(gpa, 'a' + @as(u8, @intCast(i % 26)), 100); try text.append(gpa, '\n'); } _ = try core.setTestFile(text.items); core.settings.motion = .smooth; var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const ms = std.time.ns_per_ms; var now: u64 = std.time.ns_per_s; core.advance(now); var s = try core.render(arena.allocator()); var g: Gui = undefined; g.glide = .{}; defer g.glide.deinit(gpa); g.scroll_delta = 0; g.scroll_lag = 0; g.scroll_edge_len = 0; const r = core.rects[core.active]; g.scroll_pane = core.active; g.scroll_col = r.x + 10; g.scroll_row = r.y + r.h - 2; const body_y = core.bodyTop(core.panes[core.active].?, r); const row_before = try gpa.dupe(pardes.Cell, s.cells[@as(usize, body_y + 1) * s.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER]); defer gpa.free(row_before); // One notch down: the core is a line on at once. g.scroll_notch = 1; const scrolled = core.panes[core.active].?.scroll(); stepGlide(&g, core, gpa); try std.testing.expectEqual(scrolled + 1, core.panes[core.active].?.scroll()); try std.testing.expect(g.glide.pending); // On the core's continuous path while it moves: frames every loop. try std.testing.expect(core.shell_continuous and core.continuous()); _ = arena.reset(.retain_capacity); s = try core.render(arena.allocator()); g.glide.measure(gpa, s); // Its two rows: the picture two rows behind, the rows that left kept, // nearest first. try std.testing.expectApproxEqAbs(@as(f32, -2), g.glide.offset, 1e-4); try std.testing.expectEqual(@as(u16, 2), g.glide.nabove); try std.testing.expect(sameRow(g.glide.above[0 .. r.w - config.GUTTER], row_before)); // Frame by frame it comes back, never past, and settles. var frames: usize = 0; while (g.glide.moving()) : (frames += 1) { now += 7 * ms; core.advance(now); stepGlide(&g, core, gpa); try std.testing.expect(g.glide.spring.value(now) <= 1e-4); if (frames > 200) return error.NeverSettled; } try std.testing.expect(frames > 5); try std.testing.expectEqual(@as(f32, 0), g.glide.offset); try std.testing.expect(!core.shell_continuous); // Motion off: a notch lands at once. core.settings.motion = .off; g.scroll_notch = 1; stepGlide(&g, core, gpa); try std.testing.expect(!g.glide.pending and !g.glide.moving()); } fn resetScroll(g: *Gui) void { g.scroll_pane = null; g.scroll_delta = 0; g.scroll_lag = 0; g.scroll_edge_len = 0; g.scroll_notch = 0; g.glide.stop(); } fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { stepGlide(g, core, gpa); const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { resetScroll(g); return; }; const queued_delta = g.scroll_delta; g.scroll_delta = 0; if (queued_delta == 0) { if (g.scroll_lag == 0 and g.scroll_notch == 0 and !g.glide.moving()) resetScroll(g); return; } const r = core.rects[id]; if (r.h <= pane.tag_rows) { resetScroll(g); return; } const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pane.tag_rows); const st = applyScrollDelta(g.scroll_lag, delta); var left = st.rows; while (left != 0) { const down = left > 0; left += if (down) -1 else 1; const was = pane.scroll(); core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row, } }); if (pane.scroll() == was) { resetScroll(g); return; } } g.scroll_lag = st.lag; g.scroll_rect = r; const body_y = core.bodyTop(pane, r); g.scroll_body_y = body_y; g.scroll_body_h = r.h -| pane.tag_rows; if (st.rows != 0) { const s = &core.surface; g.scroll_edge_len = 0; if (r.w > config.GUTTER and r.h > pane.tag_rows and r.x + r.w <= s.cols) { const bw = r.w - config.GUTTER; const bh = g.scroll_body_h; const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); const srow = body_y + (if (st.rows > 0) k - 1 else bh - k); if (srow < s.rows) { if (g.scroll_edge.len < bw) { gpa.free(g.scroll_edge); g.scroll_edge = gpa.alloc(pardes.Cell, bw) catch &.{}; } if (g.scroll_edge.len >= bw) { @memcpy(g.scroll_edge[0..bw], s.cells[@as(usize, srow) * s.cols + r.x + config.GUTTER ..][0..bw]); g.scroll_edge_len = bw; } } } } if (g.scroll_lag == 0) resetScroll(g); } fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { if (g.glide.offset != 0) return emitGlideRows(g, instances, base, surface, layout, win_w, win_h, page); const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; for (surface.panelTracks()) |track| if (track.active() and track.pane == scroll_pane) return 0; const r = g.scroll_rect; const bh = g.scroll_body_h; if (r.w <= config.GUTTER or bh == 0) return 0; const x0 = r.x + config.GUTTER; const y0 = g.scroll_body_y; const bw = r.w - config.GUTTER; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var n: u32 = 0; var row = y0; while (row < y0 + bh) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col = x0; while (col < x0 + bw) : (col += 1) { const sidx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page); n += 1; } } if (g.scroll_edge_len >= bw) { const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; while (i < bw) : (i += 1) { emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), g.scroll_edge[0..bw], i, null, false, false, page); n += 1; } } return n; } fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { const r = g.scroll_rect; const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h; const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; const ph = @as(f32, @floatFromInt(g.scroll_body_h)) * layout.h; const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); const y1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(py + ph))), y0, @as(i32, @intCast(sh))); return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; } fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void { if (g.native_images.fetchRemove(key)) |removed| c.SDL_ReleaseGPUTexture(g.device, removed.value); } fn clearNativeImages(g: *Gui) void { var iterator = g.native_images.valueIterator(); while (iterator.next()) |texture| c.SDL_ReleaseGPUTexture(g.device, texture.*); g.native_images.clearRetainingCapacity(); } fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) != null and place.cacheKey().eql(key)) return true; } return false; } fn snapshotHasNativeKey(g: *const Gui, key: pardes.ImageCacheKey) bool { for (g.presented_images.items) |place| if (place.key.eql(key)) return true; return false; } fn rememberPresentedImages(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { var next: std.ArrayListUnmanaged(SavedImagePlace) = .empty; var adopted = false; defer if (!adopted) next.deinit(gpa); next.ensureTotalCapacity(gpa, surface.nimages) catch return; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null or !g.native_images.contains(place.cacheKey())) continue; next.appendAssumeCapacity(SavedImagePlace.from(place)); } var old = g.presented_images; g.presented_images = next; adopted = true; old.deinit(gpa); } fn sameSavedPlacement(saved: SavedImagePlace, current: pardes.ImagePlace) bool { return saved.key.eql(current.cacheKey()) and saved.pane == current.pane and saved.serial == current.serial and saved.x == current.x and saved.y == current.y and saved.w == current.w and saved.h == current.h and saved.iw == current.iw and saved.ih == current.ih and std.meta.eql(saved.native, current.native); } fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { for (g.presented_images.items) |saved| if (sameSavedPlacement(saved, current)) return true; return false; } fn placeIntersectsBox(place: SavedImagePlace, box: pardes.animation.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); const y1: f32 = @floatFromInt(@as(u32, place.y) + place.h); return x0 < box.x + box.w and x1 > box.x and y0 < box.y + box.h and y1 > box.y; } fn ensureImageBuffers(g: *Gui, needed: u32) bool { if (needed <= g.image_capacity) return true; var capacity = @max(@as(u32, 1), g.image_capacity); while (capacity < needed) capacity = std.math.mul(u32, capacity, 2) catch return false; const buffer_size = std.math.mul( u32, capacity, @as(u32, @intCast(@sizeOf(ImageInstance))), ) catch return false; var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = buffer_size, .props = 0, }; const vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return false; var vx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = buffer_size, .props = 0, }; const vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &vx_info) orelse { c.SDL_ReleaseGPUBuffer(g.device, vbuf); return false; }; c.SDL_ReleaseGPUTransferBuffer(g.device, g.image_vxfer); c.SDL_ReleaseGPUBuffer(g.device, g.image_vbuf); g.image_vbuf = vbuf; g.image_vxfer = vxfer; g.image_capacity = capacity; return true; } fn validImageBytes(place: pardes.ImagePlace) ?u32 { if (place.iw == 0 or place.ih == 0 or place.iw > std.math.maxInt(u32) or place.ih > std.math.maxInt(u32)) return null; const pixels = std.math.mul(usize, place.iw, place.ih) catch return null; const bytes = std.math.mul(usize, pixels, 4) catch return null; if (place.rgba.len != bytes or bytes > std.math.maxInt(u32)) return null; return @intCast(bytes); } const NativeBounds = struct { w: u32, h: u32 }; fn nativeBounds(g: *const Gui, place: anytype) ?NativeBounds { const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null; const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null; return .{ .w = bound_w, .h = bound_h }; } fn nativePlaceGeometry(g: *const Gui, place: anytype) ?pardes.image.NativeGeometry { if (comptime pardes.pdf_enabled) { if (place.native.geometry) |geometry| return geometry; const bounds = nativeBounds(g, place) orelse return null; return pardes.image.nativeGeometry( place.iw, place.ih, bounds.w, bounds.h, place.native.fit, place.native.pan_x, place.native.pan_y, ); } return null; } fn nativePlaceDrawable(g: *const Gui, place: anytype) bool { if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null; const bounds = nativeBounds(g, place) orelse return false; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); return fit.w != 0 and fit.h != 0; } fn uploadNativeTexture( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace, ) !void { const byte_len = validImageBytes(place) orelse return error.BadImage; const key = place.cacheKey(); if (g.native_images.contains(key)) return; if (comptime pardes.pdf_enabled) if (place.patch) |patch| patch: { // The core changed only these rows since a revision we still hold: // write them into that texture rather than sending the whole page. var base = key; base.revision = patch.from; if (patch.y + patch.h > place.ih) break :patch; const texture = g.native_images.get(base) orelse break :patch; const row = place.iw * 4; try uploadTextureRows(g, cmd, texture, place.rgba[patch.y * row ..][0 .. patch.h * row], place.iw, patch.y, patch.h); try g.native_images.put(gpa, key, texture); _ = g.native_images.remove(base); // A transition snapshot of the last frame follows its texture. for (g.presented_images.items) |*saved| if (saved.key.eql(base)) { saved.key = key; }; return; }; var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = @intCast(place.iw); tex_info.height = @intCast(place.ih); tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUTexture(g.device, texture); try uploadTextureRows(g, cmd, texture, place.rgba[0..byte_len], place.iw, 0, place.ih); try g.native_images.put(gpa, key, texture); } /// Copy `rows` rows of RGBA, `width` pixels each, into `texture` at row `y`. fn uploadTextureRows( g: *Gui, cmd: *c.SDL_GPUCommandBuffer, texture: *c.SDL_GPUTexture, rgba: []const u8, width: usize, y: usize, rows: usize, ) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = @intCast(rgba.len), .props = 0, }; const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap); @memcpy(mapped[0..rgba.len], rgba); c.SDL_UnmapGPUTransferBuffer(g.device, transfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = transfer, .offset = 0, .pixels_per_row = @intCast(width), .rows_per_layer = @intCast(rows), }; const dst = c.SDL_GPUTextureRegion{ .texture = texture, .mip_level = 0, .layer = 0, .x = 0, .y = @intCast(y), .z = 0, .w = @intCast(width), .h = @intCast(rows), .d = 1, }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); } fn appendPreparedImage( g: *Gui, gpa: std.mem.Allocator, place: SavedImagePlace, track: ?pardes.animation.Track, clip: ?pardes.animation.Box, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; if (!nativePlaceDrawable(g, place)) return; g.prepared_images.append(gpa, .{ .place = place, .texture = texture, .track = track, .clip = clip, .old_layer = old_layer, }) catch {}; } fn emitPreparedImage( g: *Gui, instance: *ImageInstance, prepared: PreparedImage, layout: CellLayout, win_w: f32, win_h: f32, ) bool { const place = prepared.place; const body_x = @as(u32, place.x) * g.cell_w; const body_y = @as(u32, place.y) * g.cell_h; if (comptime pardes.pdf_enabled) { const geometry = nativePlaceGeometry(g, place) orelse return false; const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x)); const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) + place.native.pixel_offset_y; const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w)); const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h)); const image_w: f32 = @floatFromInt(place.iw); const image_h: f32 = @floatFromInt(place.ih); instance.* = .{ .x0 = (px0 / win_w) * 2.0 - 1.0, .y0 = 1.0 - (py0 / win_h) * 2.0, .x1 = (px1 / win_w) * 2.0 - 1.0, .y1 = 1.0 - (py1 / win_h) * 2.0, .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w, .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h, .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w, .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h, }; } else { const bounds = nativeBounds(g, place) orelse return false; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); if (fit.w == 0 or fit.h == 0) return false; instance.* = .{ .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0, .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0, .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0, .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0, .u0 = 0, .v0 = 0, .u1 = 1, .v1 = 1, }; } setTransitionFields( instance, prepared.track, layout, win_w, win_h, if (prepared.track) |active| panelGridSize(active) else 0, ); if (prepared.old_layer) instance.effect |= old_layer_bit; return true; } fn prepareNativeImages( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, groups: *Groups, sw: u32, sh: u32, ) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null) continue; uploadNativeTexture(g, gpa, cmd, place) catch continue; } while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; var iterator = g.native_images.iterator(); while (iterator.next()) |entry| { if (surfaceHasNativeKey(surface, entry.key_ptr.*) or snapshotHasNativeKey(g, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } for (stale[0..stale_len]) |key| releaseNativeImage(g, key); if (stale_len < stale.len) break; } g.prepared_images.clearRetainingCapacity(); for (groups.items[0..groups.len], 0..) |*group, group_index| { group.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; const track = group.track; if (track) |active| { if (active.phase == .closing) { for (g.presented_images.items) |saved| { if (saved.serial != active.serial) continue; appendPreparedImage(g, gpa, saved, active, null, false); } } else if (active.effect == .dissolve) { for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, active, active.contentBox(), true); } } } if (track == null or track.?.phase != .closing) { for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or groupOf(groups, current.serial) != group_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { .dissolve => if (snapshotContainsPlacement(g, current)) null else active, else => active, } else null; appendPreparedImage(g, gpa, saved, current_track, null, false); } } if (group_index == 0) for (groups.items[1..][0..groups.tracks]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, null, active.contentBox(), false); } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; group.image_count = image_end - group.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; if (capacity == 0) return true; if (!ensureImageBuffers(g, capacity)) return false; const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return false); const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr)); const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); const layout = fixedCellLayout(g); var idx: u32 = 0; for (g.prepared_images.items) |prepared| { if (!emitPreparedImage(g, &instances[idx], prepared, layout, win_w, win_h)) continue; idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); std.debug.assert(idx == capacity); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.image_vxfer, .offset = 0 }; const dst = c.SDL_GPUBufferRegion{ .buffer = g.image_vbuf, .offset = 0, .size = idx * @sizeOf(ImageInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); return true; } fn imageScissor( g: *const Gui, prepared: PreparedImage, max_w: u32, max_h: u32, ) c.SDL_Rect { if (prepared.clip) |box| return panelBoxScissor(g, box, max_w, max_h); if (prepared.track) |active| { const box = if (active.effect == .vertical) active.contentBox() else active.visualBox(); return panelBoxScissor(g, box, max_w, max_h); } const place = prepared.place; return .{ .x = @intCast(@as(u32, place.x) * g.cell_w), .y = @intCast(@as(u32, place.y) * g.cell_h), .w = @intCast(@as(u32, place.w) * g.cell_w), .h = @intCast(@as(u32, place.h) * g.cell_h), }; } fn panelBoxScissor(g: *const Gui, box: pardes.animation.Box, max_w: u32, max_h: u32) c.SDL_Rect { const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w)))); const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h)))); const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w)))); const y1: i32 = @intFromFloat(@ceil((box.y + box.h) * @as(f32, @floatFromInt(g.cell_h)))); const clipped_x0 = std.math.clamp(x0, 0, @as(i32, @intCast(max_w))); const clipped_y0 = std.math.clamp(y0, 0, @as(i32, @intCast(max_h))); const clipped_x1 = std.math.clamp(x1, clipped_x0, @as(i32, @intCast(max_w))); const clipped_y1 = std.math.clamp(y1, clipped_y0, @as(i32, @intCast(max_h))); return .{ .x = clipped_x0, .y = clipped_y0, .w = clipped_x1 - clipped_x0, .h = clipped_y1 - clipped_y0, }; } fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, batch: Group, whole: c.SDL_Rect, ) void { const pass = rp orelse return; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); const max_w = @as(u32, surface.cols) * g.cell_w; const max_h = @as(u32, surface.rows) * g.cell_h; var idx = batch.image_start; const end = batch.image_start + batch.image_count; for (g.prepared_images.items[batch.image_start..end]) |prepared| { const clip = imageScissor(g, prepared, max_w, max_h); if (clip.w > 0 and clip.h > 0) { c.SDL_SetGPUScissor(pass, &clip); const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = prepared.texture, .sampler = g.linear_sampler }; c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1); const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0); } idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); // Back to the whole target: what draws after a picture may run on past // the grid, as a rule and a band do. c.SDL_SetGPUScissor(pass, &whole); } fn renderFrame( g: *Gui, gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, debug_on: bool, ) !bool { if (surface.pointer_shape != g.pointer_shape) { g.pointer_shape = surface.pointer_shape; const cursor = switch (g.pointer_shape) { .link => g.link_cursor, .box => g.box_cursor orelse g.arrow_cursor, else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(), }; if (cursor) |value| _ = c.SDL_SetCursor(value); } g.scene_target_failed = false; g.words.memo = .{}; // positional, and this frame's cells are new const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; defer if (!command_consumed) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; const frame_target = try acquireTarget(g, cmd, @as(u32, surface.cols) * g.cell_w + g.capture_pad, @as(u32, surface.rows) * g.cell_h + g.capture_pad) orelse { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; }; const target = frame_target.texture; const sw = frame_target.w; const sh = frame_target.h; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); // The post chain runs over a local frame, from the scene texture. var scene_on = core != null and g.post.ready(); if (scene_on) ensureSceneTexture(g, sw, sh) catch { g.scene_target_failed = true; scene_on = false; }; const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target; // The frame's CPU side: groups, decor, cover and every instance, up to // their upload. const build_zone = tracy.zone(@src(), "gui frame build"); var building = true; defer if (building) build_zone.end(); const layout = fixedCellLayout(g); const chrome = &surface.chrome; // G6: what the scroll just done moved the pane's rows by, measured on // this frame against the last (the glide follows it from there). if (g.glide.pending) g.glide.measure(gpa, surface); if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns); cell_decor = CellDecor.of(chrome); // Plaques need an opaque window: with WindowOpacity under 100 the cells // are drawn as they are, and the plaques not at all. cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1; if (chrome.decor_box_border != null and !cell_decor.plaques_off) { cell_decor.grounds = .{ chrome.tag_bg, chrome.tag_focus_bg }; for (surface.regionList()) |region| { if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; if (cell_decor.nplaques == cell_decor.plaques.len) break; const plaque = plaqueBox(g, chrome, surface.regionList(), region, layout, win_w, win_h) orelse continue; cell_decor.plaques[cell_decor.nplaques] = plaque.band; cell_decor.nplaques += 1; } } var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); // Cursors stand still while panes move, as they always have; while the // focused one glides or blinks, it is the overlay's quad instead. One // or the other every frame (none only at a blink's off half). const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1; const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1); g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0; const cursors_shown = groups.tracks == 0 and !quad_mode; var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on); if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) { g.prepared_images.clearRetainingCapacity(); for (groups.items[0..groups.len]) |*group| { group.image_start = 0; group.image_count = 0; } } const cells: u32 = @as(u32, surface.cols) * surface.rows; try g.cover.resize(gpa, @as(usize, cells) * 2); const covered = g.cover.items[0..cells]; coverFrame(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers(), surface.regionList()); // Only a panel transition paints the grid under the layers it leaves. const previous_covered = g.cover.items[cells..]; if (groups.tracks != 0) coverFrame(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers, surface.previous_regions); try buildDecor(g, gpa, surface, &groups, layout, win_w, win_h, cursors_shown); const page = ground(chrome.page, g.transparent); const background_opacity = backgroundOpacity(g.applied_window_opacity); var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ .r = @as(f32, @floatFromInt(page.rgb[0])) / 255.0 * background_opacity, .g = @as(f32, @floatFromInt(page.rgb[1])) / 255.0 * background_opacity, .b = @as(f32, @floatFromInt(page.rgb[2])) / 255.0 * background_opacity, .a = background_opacity, }; color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; color_target.store_op = c.SDL_GPU_STOREOP_STORE; var shifted: u32 = 0; var row: u16 = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { const at = @as(usize, row) * surface.cols + col; if (covered[at].layer and !covered[at].floating) continue; const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); const track = groups.items[group_index].track; const data_effect = if (track) |active| active.effect == .dissolve else false; const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); const instance_count = if (data_diff) cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at]) else cellInstanceCount(&surface.cells[at]); groups.items[group_index].cell_count = std.math.add(u32, groups.items[group_index].cell_count, instance_count) catch return error.GpuCapacity; } } for (groups.items[1..][0..groups.tracks]) |*group| { const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; const at = @as(usize, row) * surface.cols + col; if (previous_covered[at].layer) continue; const destination = if (duplicate_under) &groups.items[0] else group; destination.cell_count = std.math.add(u32, destination.cell_count, cellInstanceCount(&surface.previous_cells[at])) catch return error.GpuCapacity; } } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const group = &groups.items[groupAt(&groups, layer.viewport.x, layer.viewport.y)]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; const destination = &groups.items[if (under) 0 else index]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; destination.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else groupAt(&groups, layer.viewport.x, layer.viewport.y); groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0); var cell_total: u32 = 0; for (groups.items[0..groups.len], 0..) |*group, index| { group.cell_start = cell_total; cell_next[index] = cell_total; cell_total = std.math.add(u32, cell_total, group.cell_count) catch return error.GpuCapacity; } const decor_len: u32 = @intCast(g.decor.items.len); if (cell_total != 0 or decor_len != 0) { // The shifted rows of a scroll: its body, and a glide's rows past it. const capacity = std.math.add(u32, cell_total, 2 * cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; }); const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; const cursor_bar = if (surface.cursor) |cu| cu.bar else false; row = 0; while (row < surface.rows) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; const line_cover = covered[@as(usize, row) * surface.cols ..][0..surface.cols]; const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col: u16 = 0; while (col < surface.cols) : (col += 1) { const at = @as(usize, row) * surface.cols + col; if (covered[at].layer and !covered[at].floating) continue; const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); const track = groups.items[group_index].track; const data_effect = if (track) |active| active.effect == .dissolve else false; const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); if (data_diff) emitSurfaceCell( g, chrome, instances, &cell_next[group_index], col, row, layout, win_w, win_h, track, surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, null, true, false, page, ); emitSurfaceCell( g, chrome, instances, &cell_next[group_index], col, row, layout, win_w, win_h, if (data_effect and !data_diff) null else track, line, line_cover, col, line_cursor, false, cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } } // Body layers first: a notice chip over a tree-sitter context band // is a tag layer, and instances paint in emission order. for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const group_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); emitBodyLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[group_index].track, page, cursors_shown, false, chrome.topbar_rule); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); // A notice floats over the panes, moving with its own. const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index; const start = cell_next[group_index]; emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); // A notice dropping past its row (a flavour's overshoot) stays // inside its pane's body. if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| { for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h); }; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; emitTagLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; emitBodyLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, chrome.topbar_rule); } } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue; emitSurfaceCell( g, chrome, instances, &cell_next[if (duplicate_under) 0 else index], col, row, layout, win_w, win_h, if (duplicate_under) null else track, surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, null, false, false, page, ); } } } for (groups.items[0..groups.len], 0..) |group, index| std.debug.assert(cell_next[index] == group.cell_start + group.cell_count); shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); @memcpy(instances[cell_total + shifted ..][0..decor_len], g.decor.items); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); if (g.atlas_dirty_top < g.atlas_dirty_bottom) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted + decor_len) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); } build_zone.end(); building = false; const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); pushLayerOpacity(cmd, rp, background_opacity); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; const decor_base = cell_total + shifted; for (groups.items[0..groups.len], 0..) |group, index| { if (group.under_count != 0 and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); pushLayerOpacity(cmd, rp, background_opacity); const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, group.under_count, 0, 0); } const has_shifted = index == 0 and shifted != 0; if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0) g.pipeline_over else g.pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); if (group.cell_count != 0) { const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = group.cell_start * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, group.cell_count, 0, 0); } if (has_shifted) { const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); c.SDL_SetGPUScissor(rp, &whole); } } if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); if (group.decor_count > group.under_count and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); // Ink keeps its colour at any WindowOpacity. pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity); const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start + group.under_count) * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count - group.under_count, 0, 0); } } drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); if (scene_on) { if (core) |p| { var boxes: [max_cursors]CursorBox = undefined; const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; var spare: [Post.max_spare][4]f32 = undefined; g.post.describe(.{ .cursor = if (cursors > 0) boxes[0] else null, .spare = spare[0..postSpare(surface, layout, &spare)], .background = chrome.page orelse bg_default, .foreground = chrome.fg orelse fg_default, .selection_foreground = p.theme().sel_fg, .selection_background = p.theme().sel_bg, }); } const chain_zone = tracy.zone(@src(), "post chain"); defer chain_zone.end(); g.post.scale = display_scale; g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch { g.scene_target_failed = true; }; } command_consumed = true; const shown = try finishFrame(g, gpa, cmd, frame_target, if (groups.tracks != 0) groups.items[1].track.?.frame else null); if (shown and groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return shown; } const Target = struct { texture: *c.SDL_GPUTexture, w: u32, h: u32 }; /// What this frame draws into: the capture texture in test mode (the grid's /// size plus PARDES_TEST_PAD) or with a soft presenter, else the swapchain's /// image; null when there is none to be had this time. fn acquireTarget(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, capture_w: u32, capture_h: u32) !?Target { if (g.capture) { if (capture_w == 0 or capture_h == 0) return null; try ensureCaptureTexture(g, capture_w, capture_h); return .{ .texture = g.capture_tex.?, .w = capture_w, .h = capture_h }; } if (g.soft_present) { var pw: c_int = 0; var ph: c_int = 0; if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) return null; try ensureCaptureTexture(g, @intCast(pw), @intCast(ph)); return .{ .texture = g.capture_tex.?, .w = @intCast(pw), .h = @intCast(ph) }; } var swap_tex: ?*c.SDL_GPUTexture = null; var sw: u32 = 0; var sh: u32 = 0; if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) return null; // A busy swapchain skips the frame. return .{ .texture = swap_tex orelse return null, .w = sw, .h = sh }; } /// Submits the frame: captured, handed to the soft presenter, or presented. fn finishFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: Target, transition: ?u16) !bool { if (g.capture) { try captureFrame(g, gpa, cmd, target.texture, target.w, target.h, transition); return true; } if (g.soft_present) { try softPresentFrame(g, cmd, target.texture, target.w, target.h); return true; } return c.SDL_SubmitGPUCommandBuffer(cmd); } /// Redraw level A (docs §5.5): the post chain alone, over the frame the core /// last drew, for a pass that moves on its own. No core render, no cell. fn redrawPost(g: *Gui, gpa: std.mem.Allocator) void { const scene = g.scene_tex orelse return; const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; const target = (acquireTarget(g, cmd, g.scene_tex_w, g.scene_tex_h) catch null) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; }; // A window the retained frame no longer fits waits for the core's. if (target.w != g.scene_tex_w or target.h != g.scene_tex_h) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; } const zone = tracy.zone(@src(), "post chain redraw"); g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, scene, target.texture, target.w, target.h, c.SDL_GetTicksNS(), backgroundOpacity(g.applied_window_opacity) >= 1) catch {}; zone.end(); _ = finishFrame(g, gpa, cmd, target, null) catch {}; } const ResolvedCell = struct { slot: Slot, fg: [3]u8, bg: [3]u8, role: pardes.FontRole, clear_bg: bool = false, }; const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; const canvas_h: f32 = 200; // Workspace, column and pane rows alike: text lines up across bands. for ([_]u16{ 0, 1, 2, 5, 8 }) |row| try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(row, canvas_h, cell_h, tagline_h)); try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); } /// The colours and atlas slot of `line[at]`. `cursor` is where the cursor sits /// in `line`, block or bar: shaping breaks around it. fn resolveCell(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { const cell = &line[at]; var fg = fg_default; var bg = page.rgb; var clear_bg = page.clear; var reverse = is_cursor; if (!cell.default) { const st = cell.style; fg = switch (st.fg) { .default => fg_default, .index => |i| palColor(i), .rgb => |rgb| rgb, }; switch (st.bg) { .default => {}, .index => |i| { bg = palColor(i); clear_bg = false; }, .rgb => |rgb| { bg = rgb; clear_bg = false; }, } if (st.reverse) reverse = !reverse; if (st.invisible) { fg = bg; // With a translucent background, painting bg-colored glyphs at // full opacity would expose text that the terminal concealed, so // cellSlot draws a concealed cell blank. } if (st.dim) for (&fg) |*ch| { ch.* = @intCast(@as(u16, ch.*) * 6 / 10); }; } if (reverse) { std.mem.swap([3]u8, &fg, &bg); clear_bg = false; } return .{ .slot = cellSlot(g, line, at, cursor, role), .fg = fg, .bg = bg, .role = role, .clear_bg = clear_bg, }; } fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole { return if (cell.default) .body else cell.style.font_role; } test "concealed terminal text emits no ink on opaque or transparent ground" { var g: Gui = undefined; g.space_slot = .{ .u = 0, .v = 0 }; g.words = .{}; var cell: pardes.Cell = .{ .default = false, .style = .{ .invisible = true, .ul = .single } }; cell.text[0] = 'X'; for ([_]Ground{ .opaqueRgb(bg_default), .{ .rgb = bg_default, .clear = true } }) |page| { const resolved = resolveCell(&g, (&cell)[0..1], 0, null, .body, false, page); try std.testing.expectEqualDeep(g.space_slot, resolved.slot); try std.testing.expectEqual(page.rgb, resolved.bg); } } fn cellInstanceCount(cell: *const pardes.Cell) u32 { return if (cellFontRole(cell) == .tagline) 2 else 1; } fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; if (layer.rows > 0 and row >= r.y and row - r.y < layer.rows and col >= r.x and col - r.x < r.w) return layer; } return null; } fn expectCoverIsScan(cover: []const Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; try std.testing.expectEqual(scan, cover[row * cols + col].layer); }; } test "the layer cover is the per-cell layer scan, over a core's layouts and random layers" { // A gui-shaped core: its tagline is narrower and shorter than the body, // so tags and notices are layers. const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 97, .rows = 31 }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 97, .rows = 31, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 7, .tagline_h = 13 } } }); for (1..4) |id| _ = try core.newShell(id, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); pardes.layout.insert(core, 1, 1, 2); pardes.layout.insert(core, 0, 1, 3); var cover: [97 * 31]Cover = undefined; var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse")); core.leader_on = step & 4 != 0; if (step & 8 != 0) { try std.testing.expect(core.executeBuiltinLine(0, "Msg a message chip over the body")); try std.testing.expect(core.executeBuiltinLine(3, "Msg another")); } const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows); @memset(&cover, .{ .layer = true, .floating = true, .grip = true, .focus = true }); // what an earlier frame left coverFrame(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); // A grip's cells know their way to its glyph, and the focused // pane's tag rows are the focused ones. for (s.regionList()) |region| { if (region.kind != .grip) continue; for (0..region.rect.w) |offset| { const cell = cover[@as(usize, region.rect.y) * s.cols + region.rect.x + offset]; try std.testing.expect(cell.grip); try std.testing.expectEqual(offset, cell.grip_offset); try std.testing.expectEqual(region.owner == core.active, cell.focus); } } for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); // Random layers: overlapping, empty, past the grid's right and bottom // edges, and at the top of u16, where x + w overflows it. const gen = struct { fn coordinate(random: std.Random, limit: u16) u16 { if (random.uintLessThan(u8, 12) == 0) return std.math.maxInt(u16) - random.uintLessThan(u16, 3); return random.uintAtMost(u16, limit + 3); } fn rect(random: std.Random, cols: u16, rows: u16) pardes.Rect { return .{ .x = coordinate(random, cols), .y = coordinate(random, rows), .w = coordinate(random, cols), .h = coordinate(random, rows / 2) }; } }; var prng = std.Random.DefaultPrng.init(0x6c61796572); const random = prng.random(); var body: [pardes.MAX_PANES]pardes.Layer = undefined; var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; var grid: [64 * 40]Cover = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); const rows = random.intRangeAtMost(u16, 1, 40); const empty = random.intRangeAtMost(u8, 2, 5); // about one layer in `empty` is empty for (&body) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; for (&tag) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; const bodies = body[0..random.uintAtMost(usize, body.len)]; const tags = tag[0..random.uintAtMost(usize, tag.len)]; const cells = grid[0 .. @as(usize, cols) * rows]; @memset(cells, .{ .layer = random.boolean() }); coverFrame(cells, cols, rows, bodies, tags, &.{}); try expectCoverIsScan(cells, cols, rows, bodies, tags); } } /// The bottom of pane `owner`'s body, in window pixels. fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 { for (surface.regionList()) |region| { if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h; } return null; } fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); } fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const background: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = layer.bg } } }; // A band sliding into place is drawn offset and cut at its row's top, so // it comes out from under whatever is above it. const slide = layer.slide * bh; // A window is rarely a whole number of cells wide: a band that ends at // the last column runs on through the leftover pixels to the edge. const band_right = if (win_w - right < bw) @max(right, win_w) else right; // A row to each line, each a grid row with the tagline's glyphs. for (0..layer.rows) |index| { const y = layer.viewport.y + @as(u16, @intCast(index)); const top = @as(f32, @floatFromInt(y)) * bh; emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); // A theme's plaque is drawn under the band: the band's own ground // lets it show (tagPlaques). if (cell_decor.nplaques != 0 and cell_decor.inPlaque(left + 0.5, top + 0.5)) instances[next.*].effect |= clear_bg_bit; if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; const cells = layer.cells[index * layer.cols ..][0..layer.cols]; const line_cursor: ?usize = if (layer.cursor) |cu| (if (cu.y == index) cu.x else null) else null; for (0..tagLayerCellCount(g, layer)) |col| { const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == index else false; emitInstance(g, instances, next.*, @intCast(col), y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, cells, col, line_cursor, old_layer, cursor, page); clipInstanceRight(&instances[next.*], right, win_w); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; } } } fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { const limit = 1 - top / win_h * 2; if (instance.y0 <= limit) return; const fraction = std.math.clamp((limit - instance.y1) / (instance.y0 - instance.y1), 0, 1); instance.v0 = instance.v1 + (instance.v0 - instance.v1) * fraction; instance.y0 = @max(instance.y1, limit); } fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.Layer { return bodyLayerIn(surface.bodyLayers(), col, row); } fn bodyLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; if (layer.rows > 0 and col >= r.x and col - r.x < r.w and row >= r.y and row - r.y < r.h) return layer; } return null; } fn clipInstanceRight(instance: *CellInstance, right: f32, win_w: f32) void { const edge = right / win_w * 2 - 1; if (instance.x1 <= edge) return; const fraction = std.math.clamp((edge - instance.x0) / (instance.x1 - instance.x0), 0, 1); instance.u1 = instance.u0 + (instance.u1 - instance.u0) * fraction; instance.x1 = @max(instance.x0, edge); } fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void { const limit = 1 - bottom / win_h * 2; if (instance.y1 >= limit) return; const fraction = (instance.y0 - limit) / (instance.y0 - instance.y1); instance.v1 = instance.v0 + (instance.v1 - instance.v0) * fraction; instance.y1 = limit; } fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); const th: f32 = @floatFromInt(g.tagline_height); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * bh; for (0..layer.rows) |index| { const row: u16 = @intCast(index); const top = layer.rowTop(row, bh, th); if (top >= bottom) break; const height = layer.rowHeight(row, bh, th); const header = row < layer.context_rows; const role: pardes.FontRole = if (header) .tagline else .body; const width = if (header) tw else bw; if (header) { var background: pardes.Cell = .{ .default = false, .style = layer.cells[index * layer.cols].style }; background.style.font_role = .body; emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; } const line = layer.cells[index * layer.cols ..][0..layer.cols]; const line_cursor: ?usize = if (layer.cursor) |cu| if (cu.y == row) cu.x else null else null; for (0..layer.cols) |col| { if (left + @as(f32, @floatFromInt(col)) * width >= right) break; const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == row else false; const glyph_top = top - (if (header) @as(f32, @floatFromInt(taglineBandOffset(0, win_h, g.cell_h, g.tagline_height))) else 0); emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, line, col, line_cursor, old_layer, cursor, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; } if (layer.hasContextBorderAfter(row)) { const border: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = border_rgb } } }; emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = 1, .x_off = left, .y_off = top + height - 1, }, win_w, win_h, track, .body, (&border)[0..1], 0, null, old_layer, false, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; } } } /// A dissolving cell changes with its grip's glyph, which is the cell its /// grip offset to the left, in this frame's grip or the last one's. fn gripCellChanged(surface: *const pardes.Surface, covered: []const Cover, previous_covered: []const Cover, col: u16, row: u16) bool { if (surface.panelCellChanged(col, row)) return true; const at = @as(usize, row) * surface.cols + col; for ([_]Cover{ covered[at], previous_covered[at] }) |cover| if (cover.grip and surface.panelCellChanged(col -| cover.grip_offset, row)) return true; return false; } fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } /// acme's Scrollwid column at a pane's or column's left edge, in window /// pixels: where its button sits over its scrollbar, one width for both /// (`rail_px` at acme's 17px font, scaled with the tagline, and always /// short of the cell so the text keeps a gap), right of the column's rule. fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 { const rule: f32 = if (col == 0) 0 else rulePx(chrome); const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule); const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17); // The rail and the grip are GUTTER cells wide; the text starts after. const w = std.math.clamp(scaled, dp(4), @max(dp(4), @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - dp(3))); return .{ x0, x0 + w }; } /// acme's button for the grip whose first cell is (col, row), in window /// pixels: the scroll column wide, its tag row tall, from the rule above to /// the rule below, so button and scrollbar read as one strip. fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 { _ = win_h; const x = scrollColumn(g, chrome, col, layout); const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h); return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) }; } /// A pane's button fill, from its region's state, not the grid's cells /// (which are drawn for cell shells): dirty, focused, or the tag's ground. fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 { if (dirty) return chrome.grip_dirty; return if (focus) chrome.grip_focus_ring else chrome.tag_bg; } fn inkOn(bg: [3]u8) [3]u8 { const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; } /// Cuts an instance's quad to a pixel rectangle, its atlas window with it so /// the glyph neither moves nor stretches; nothing left, an empty quad. fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void { const x0 = (instance.x0 + 1) / 2 * win_w; const x1 = (instance.x1 + 1) / 2 * win_w; const y0 = (1 - instance.y0) / 2 * win_h; const y1 = (1 - instance.y1) / 2 * win_h; const left = @max(0, r[0] - x0); const right = @max(0, x1 - r[2]); const top = @max(0, r[1] - y0); const bottom = @max(0, y1 - r[3]); if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) { instance.x1 = instance.x0; instance.y1 = instance.y0; return; } instance.x0 += left / win_w * 2; instance.u0 += left / atlas_w; instance.x1 -= right / win_w * 2; instance.u1 -= right / atlas_w; instance.y0 -= top / win_h * 2; instance.v0 += top / atlas_h; instance.y1 += bottom / win_h * 2; instance.v1 -= bottom / atlas_h; } fn emitSurfaceCell( g: *Gui, chrome: *const pardes.Chrome, instances: [*]CellInstance, next: *u32, col: u16, row: u16, body_layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track, line: []const pardes.Cell, line_cover: []const Cover, at: usize, cursor: ?usize, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = cellFontRole(&line[at]); if (role == .tagline) { const here = line_cover[at]; const band_base: pardes.Cell = .{ .style = .{ .bg = .{ .rgb = if (here.focus) chrome.tag_focus_bg else chrome.tag_bg }, .font_role = .tagline }, .default = false, }; // The smaller font changes the ink, not the chrome's coverage. Painting // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); // Over a theme's plaque (not a grip's button) the underlay lets it // show (tagPlaques). if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5)) instances[next.*].effect |= clear_bg_bit; next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. // A pane's grip cells are the cell shells' form (a dirty `*`): the // button draws its own glyph over a blank of them. var blank = line[at]; blank.text[0] = ' '; blank.len = 1; if (here.pane_grip) emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page) else emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); // A grip's glyph is the cell at its left edge, and a blank one is // no glyph: its marks are decor. const glyph = &line[at - @min(here.grip_offset, at)]; const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; // A grip is acme's button: its fill is this cell's ground, cut to // the button (and a dirty one's inset a pixel inside its ring, so // the ring reads round it), its glyph centered in it. const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null; if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; const b = button.?; const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w; const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0; const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg; instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255; instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; if (button) |b| { // acme's button fills its tag row from the rule above to the rule // below: the underlay is its fill, cut to its columns; a pane's // from its region's state, not the grid's cell form (whose dirty // `*` is the cell shells'), a column's its cell's own ground. const base = &instances[next.* - 1]; const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb }; if (here.pane_grip) { const fill = gripFill(chrome, here.focus, here.dirty); base.br = @as(f32, @floatFromInt(fill[0])) / 255; base.bg = @as(f32, @floatFromInt(fill[1])) / 255; base.bb = @as(f32, @floatFromInt(fill[2])) / 255; } else { base.br = own[0]; base.bg = own[1]; base.bb = own[2]; } base.effect &= ~clear_bg_bit; clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h); // Over it, only its own glyph; the band round it and the ring // are decor (gripDecor). if (here.pane_grip and !grip) { instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w; instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h; } // Its ground is the fill again (a see-through ground would be // written, not blended, in an opaque window). instances[next.*].br = base.br; instances[next.*].bg = base.bg; instances[next.*].bb = base.bb; instances[next.*].effect &= ~clear_bg_bit; clipInstance(&instances[next.*], b, win_w, win_h); } else if (inset > 0) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } next.* += 1; return; } emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, line, at, cursor, old_layer, is_cursor, page); next.* += 1; } test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 }); defer core.deinit(); const pane = try core.setTestFile("text\n" ** 60); pane.tag.own = try gpa.dupe(u8, "second\nthird"); core.newScratchBelow(0); for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| { core.settings.tag_bottom = bottom; core.active = focus; pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); var g: Gui = undefined; g.words = .{}; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = m.w; g.cell_h = m.h; g.tagline_width = m.w; g.tagline_height = m.tag; g.space_slot = .{ .u = 0, .v = 0 }; const layout = fixedCellLayout(&g); const win_w: f32 = @floatFromInt(60 * m.w); const win_h: f32 = @floatFromInt(20 * m.h); const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows); defer gpa.free(cover); coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true); const grip = s.region(.grip, 0).?; // The grip column's pixels, band first, then its cells, then decor. const x_px: u32 = grip.rect.x * m.w; const w_px: u32 = config.GUTTER * m.w; const y_px: u32 = grip.rect.y * m.h; const h_px: u32 = grip.rect.h * m.h; const pixels = try gpa.alloc(?[3]f32, w_px * h_px); defer gpa.free(pixels); @memset(pixels, null); const paint = struct { fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void { const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww)); const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww)); const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh)); const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh)); var y = @max(py0, oy); while (y < @min(py1, oy + ph)) : (y += 1) { var x = @max(px0, ox); while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb; } } }; var instances: [4]CellInstance = undefined; for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| { const col: u16 = grip.rect.x + @as(u16, @intCast(dx)); const row: u16 = grip.rect.y + @as(u16, @intCast(line)); const at = @as(usize, row) * s.cols; var next: u32 = 0; emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default)); for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); }; for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); const rgb = struct { fn of(v: [3]u8) [3]f32 { return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 }; } }.of; const chrome = &s.chrome; const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg; // Band, fills, rings, the rules between stacked panes and under the // tag, and the mode marks' ink (the grip's first cell's). const ink = inkOn(gripFill(chrome, grip.active, grip.dirty)); const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) }; for (pixels, 0..) |pixel, n| { const got = pixel orelse return error.TestUnpaintedGripPixel; const known = for (allowed) |a| { if (std.meta.eql(a, got)) break true; } else false; // Under the first row there is no button: band and rules only. if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border))) return error.TestButtonUnderFirstRow; if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page }); try std.testing.expect(known); } // The rule under the tag runs unbroken across the grip column. if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel| try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?); // The button's edges are the scrollbar's. const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h); const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout); try std.testing.expectEqual(column[0], button[0]); try std.testing.expectEqual(column[1], button[2]); }; pane.file.?.revision = pane.file.?.saved_revision; } test "a rising pane past its mark stays inside its box" { // Bouncy, a vertical rise at its highest overshoot: drawn past its box, // clipped to it. var g: Gui = undefined; g.cell_w = 10; g.cell_h = 20; var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } }; var peak: u16 = 0; var highest: f32 = 1e9; while (track.frame < track.frames()) : (track.frame += 1) { if (track.presented().y < highest) { highest = track.presented().y; peak = track.frame; } } track.frame = peak; try std.testing.expect(track.presented().y < 0); var instance: CellInstance = std.mem.zeroes(CellInstance); setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0); try std.testing.expectEqual(@as(f32, 0), instance.clip_y0); try std.testing.expectEqual(@as(f32, 200), instance.clip_y1); } test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; g.tagline_height = 16; g.space_slot = .{ .u = 0, .v = 0 }; const cell: pardes.Cell = .{ .default = false, .style = .{ .font_role = .tagline } }; var instances: [6]CellInstance = undefined; var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. emitSurfaceCell(&g, &chrome, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), ink.y0 - ink.y1, 0.0001); try std.testing.expect(ink.y0 <= base.y0 and ink.y1 >= base.y1); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), ink.x1 - ink.x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 10) / atlas_w, ink.u1 - ink.u0, 0.0001); if (row > 0) try std.testing.expectApproxEqAbs(instances[(row - 1) * 2].y1, base.y0, 0.0001); } // Column 0 is an anchor's edge: its ink is inset by the band's margin // (2 px here) and its underlay still covers the whole cell. next = 0; emitSurfaceCell(&g, &chrome, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{ .anchor = true }}, 0, null, false, false, Ground.opaqueRgb(bg_default)); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; emitSurfaceCell(&g, &chrome, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001); } fn emitInstance( g: *Gui, instances: [*]CellInstance, idx: u32, col: u16, row: u16, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track, role: pardes.FontRole, line: []const pardes.Cell, at: usize, cursor: ?usize, old_layer: bool, is_cursor: bool, page: Ground, ) void { const resolved = resolveCell(g, line, at, cursor, role, is_cursor, page); const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const visual_h: f32 = if (resolved.role == .tagline) @floatFromInt(g.tagline_height) else layout.h; const band_offset: f32 = if (resolved.role == .tagline) @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height)) else 0; const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + band_offset; const x0 = (px0 / win_w) * 2.0 - 1.0; const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; const y0 = 1.0 - (py0 / win_h) * 2.0; const y1 = 1.0 - ((py0 + visual_h) / win_h) * 2.0; const aw_f: f32 = @floatFromInt(atlas_w); const ah_f: f32 = @floatFromInt(atlas_h); const uv_w: u32 = if (resolved.role == .tagline) g.tagline_width else g.cell_w; const uv_h: u32 = if (resolved.role == .tagline) g.tagline_height else g.cell_h; instances[idx] = .{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1, .u0 = @as(f32, @floatFromInt(resolved.slot.u)) / aw_f, .v0 = @as(f32, @floatFromInt(resolved.slot.v)) / ah_f, .u1 = @as(f32, @floatFromInt(resolved.slot.u + uv_w)) / aw_f, .v1 = @as(f32, @floatFromInt(resolved.slot.v + uv_h)) / ah_f, .fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255.0, .fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255.0, .fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255.0, .br = @as(f32, @floatFromInt(resolved.bg[0])) / 255.0, .bg = @as(f32, @floatFromInt(resolved.bg[1])) / 255.0, .bb = @as(f32, @floatFromInt(resolved.bg[2])) / 255.0, .panel_x0 = 0, .panel_y0 = 0, .panel_x1 = 0, .panel_y1 = 0, .present_x0 = 0, .present_y0 = 0, .present_x1 = 0, .present_y1 = 0, .effect = 0, .progress_bits = @bitCast(@as(f32, 1)), .serial = 0, .cell_coord = 0, }; setTransitionFields( &instances[idx], track, fixedCellLayout(g), win_w, win_h, if (track) |active| panelCellCoord(active, col, row) else 0, ); if (old_layer) instances[idx].effect |= old_layer_bit; if (resolved.clear_bg) instances[idx].effect |= clear_bg_bit; // A block cursor is foreground UI ink, even though its color is carried // in the reversed cell background. Keep it visible at WindowOpacity 0. if (is_cursor) instances[idx].effect |= opaque_bg_bit; // The theme's ornament (cell_decor): dots on the page's own ground, a // shadow under a file name's letters. Never on a cursor. if (!is_cursor) { // A blank cell on a tag's ground inside a plaque: the plaque shows. if (cell_decor.nplaques != 0 and resolved.role == .tagline and cellCodepoint(&line[at]) == ' ' and (std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[0]) or std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[1])) and (cell_decor.inPlaque(px0 + 0.5, py0 + 0.5) or cell_decor.inPlaque(px0 + layout.w - 0.5, py0 + 0.5))) instances[idx].effect |= clear_bg_bit; if (cell_decor.dots) |dotted| { if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) instances[idx].effect |= dotted_bit; } if (cell_decor.title) |inks| { if (resolved.role == .tagline and (std.mem.eql(u8, &resolved.fg, &inks[0]) or std.mem.eql(u8, &resolved.fg, &inks[1]))) instances[idx].effect |= title_bit; } } } test "block cursor alone overrides transparent cell background" { var g: Gui = undefined; g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.space_slot = .{ .u = 0, .v = 0 }; const cell: pardes.Cell = .{}; var instances: [2]CellInstance = undefined; for ([_]bool{ false, true }, 0..) |cursor, i| { emitInstance(&g, &instances, @intCast(i), 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&cell)[0..1], 0, null, false, cursor, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(cursor, instances[i].effect & opaque_bg_bit != 0); } } test "lapis dots only the page's own ground and shadows only a file name, never a cursor" { var g: Gui = undefined; g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; g.tagline_height = 16; g.space_slot = .{ .u = 0, .v = 0 }; const page: [3]u8 = .{ 0x0a, 0x10, 0x30 }; const name: [3]u8 = .{ 0xe8, 0xc4, 0x6a }; var chrome: pardes.Chrome = .{ .page = page, .decor_dots = .{ 1, 2, 3 }, .decor_dot_alpha = 0x22, .decor_title = .{ 4, 5, 6 }, .name_ink = name, .active_name_ink = name }; cell_decor = CellDecor.of(&chrome); defer cell_decor = .{}; const on_page: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 200, 200, 200 } }, .bg = .{ .rgb = page } } }; const selected: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = name } } }; const title: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = name }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; const plain_tag: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0x9a, 0xa6, 0xd9 } }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; var instances: [1]CellInstance = undefined; const Case = struct { cell: pardes.Cell, role: pardes.FontRole, cursor: bool, dotted: bool, shadowed: bool }; for ([_]Case{ .{ .cell = on_page, .role = .body, .cursor = false, .dotted = true, .shadowed = false }, .{ .cell = on_page, .role = .body, .cursor = true, .dotted = false, .shadowed = false }, .{ .cell = selected, .role = .body, .cursor = false, .dotted = false, .shadowed = false }, .{ .cell = title, .role = .tagline, .cursor = false, .dotted = false, .shadowed = true }, .{ .cell = title, .role = .tagline, .cursor = true, .dotted = false, .shadowed = false }, .{ .cell = plain_tag, .role = .tagline, .cursor = false, .dotted = false, .shadowed = false }, }) |case| { emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, case.role, (&case.cell)[0..1], 0, null, false, case.cursor, Ground.opaqueRgb(page)); try std.testing.expectEqual(case.dotted, instances[0].effect & dotted_bit != 0); try std.testing.expectEqual(case.shadowed, instances[0].effect & title_bit != 0); } // A theme without decor marks nothing. chrome = .{ .page = page }; cell_decor = CellDecor.of(&chrome); emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&on_page)[0..1], 0, null, false, false, Ground.opaqueRgb(page)); try std.testing.expectEqual(@as(u32, 0), instances[0].effect & (dotted_bit | title_bit)); } test "lapis plaques stay in their tag's band, clear of every grip; only the focused one is striped" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); try std.testing.expect(core.executeBuiltinLine(0, "Theme lapis")); for (0..40) |_| core.update(.tick); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); try std.testing.expect(s.chrome.decor_box_border != null and s.chrome.decor_checker != null and s.chrome.decor_dots != null and s.chrome.decor_title != null); var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 16; g.cell_h = 32; g.tagline_width = 12; g.tagline_height = 22; display_scale = 1; var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true); const layout = fixedCellLayout(&g); const under = g.decor.items[groups.items[0].decor_start..][0..groups.items[0].under_count]; try std.testing.expect(under.len != 0); var stripes: usize = 0; var shadows: usize = 0; for (under) |item| { const r: [4]f32 = .{ (item.x0 + 1) / 2 * 1280, (1 - item.y0) / 2 * 768, (item.x1 + 1) / 2 * 1280, (1 - item.y1) / 2 * 768 }; // Inside one tag's band, whole: never on a body, a grip or the // next pane. const home = for (s.regionList()) |region| { if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue; if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region; } else return error.PlaqueOutsideItsBand; for (s.regionList()) |grip| { if (grip.kind != .grip and grip.kind != .column_grip) continue; const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16; const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16; const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32; const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32; try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01); } if (item.effect & checker_bit != 0) { try std.testing.expect(home.kind == .tag and home.active); stripes += 1; } const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; if (std.mem.eql(u8, &rgb, &s.chrome.decor_box_shadow.?)) shadows += 1; } try std.testing.expectEqual(@as(usize, 1), stripes); try std.testing.expect(shadows >= 2); // The scroll track is chequered. var checkers: usize = 0; for (g.decor.items[groups.items[0].decor_start + groups.items[0].under_count ..]) |item| checkers += @intFromBool(item.effect & checker_bit != 0); try std.testing.expect(checkers >= 1); // No other theme draws any of it. try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); for (0..40) |_| core.update(.tick); const plain = try core.render(core.scratch.allocator()); try std.testing.expect(plain.chrome.decor_box_border == null and plain.chrome.decor_checker == null and plain.chrome.decor_dots == null and plain.chrome.decor_title == null); } fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); } test "insert cursor overlays without replacing the character beneath it" { const cell: pardes.Cell = .{ .text = .{ 'x', ' ', ' ', ' ', ' ', ' ', ' ' }, .len = 1, .default = false, }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); // A bar at a tag cell: tagline-tall, centered in its row, in the // cell's ink, drawn as decor over it. var cells: [25]pardes.Cell = @splat(.{}); cells[3 * 5 + 2] = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 32, 48, 64 } }, .font_role = .tagline } }; const surface: pardes.Surface = .{ .cols = 5, .rows = 5, .cells = &cells, .cursor = .{ .x = 2, .y = 3, .bar = true } }; var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(std.testing.allocator); g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; g.tagline_height = 8; try cursorDecor(&g, std.testing.allocator, &surface, fixedCellLayout(&g), 100, 100); try std.testing.expectEqual(@as(usize, 1), g.decor.items.len); const bar = g.decor.items[0]; try std.testing.expectApproxEqAbs(@as(f32, -0.32), bar.y0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.48), bar.y1, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), bar.br, 0.0001); try std.testing.expectEqual(@as(f32, 1), bar.fr); } fn palColor(idx: u8) [3]u8 { const p = ghostty_vt.color.default[idx]; return .{ p.r, p.g, p.b }; } fn firstCp(s: []const u8) u32 { if (s.len == 0) return ' '; const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; if (n > s.len) return ' '; return std.unicode.utf8Decode(s[0..n]) catch ' '; } fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(g.font, g.px, &cw, &chh, &asc); g.cell_w = @intCast(@max(cw, 1)); g.cell_h = @intCast(@max(chh, 1)); g.ascent = @intCast(@max(asc, 1)); const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, g.tagline_percent); g.tagline_px = tagline.px; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; resetGlyphAtlas(g); if (core) |p| { const geom = windowCells(g); _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height); } resetScroll(g); } // The first cell of the cleared atlas stays blank: the space slot. fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); g.words.clear(); @memset(g.atlas_stage, 0); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; g.pen_y = 0; g.atlas_dirty_top = 0; g.atlas_dirty_bottom = atlas_h; } /// A changed Ligatures setting drops what was resolved under the old one: the /// per-codepoint cells (which say whether a cell is shaped) and the shaped /// words. The atlas keeps its glyphs, plain and strip alike; the plain ones /// draw either way, and the strips come back into use when ligatures do. /// The frame this runs before redraws every cell with the new setting. fn syncLigatures(g: *Gui, core: *pardes.Pardes) void { if (core.settings.ligatures == g.words.ligatures) return; g.words.clear(); g.words.ligatures = core.settings.ligatures; } fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void { const percent = core.settings.font.tagline_percent; if (percent == g.tagline_percent) return; g.tagline_percent = percent; const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, percent); g.tagline_px = tagline.px; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; resetGlyphAtlas(g); const geom = windowCells(g); _ = updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height); } /// What text draws, so a frame does not work it out again. `cells` is the /// atlas slot of a codepoint no ligature can touch (`shape_slot` for one that /// is shaped with its word); `map` is the atlas slot of every cell of a word /// holding one, found by the word's text. Bounded: past `max_cells` word cells /// it starts over, and the next frames look up what they show again (the /// glyphs stay in the atlas). const Words = struct { cells: std.AutoHashMapUnmanaged(CellKey, Slot) = .empty, map: std.AutoHashMapUnmanaged(u64, Word) = .empty, /// Every word's codepoints and slots, end to end; a Word indexes both. text: std.ArrayListUnmanaged(u32) = .empty, slots: std.ArrayListUnmanaged(Slot) = .empty, shaped: std.ArrayListUnmanaged(c.UIShapedCell) = .empty, /// The Ligatures setting. Off, no cell is shaped, so each draws its own /// glyph, as in a font without ligatures. It lives here because every /// cell and word above was resolved under it (`syncLigatures`). ligatures: bool = true, /// The word the last cell was in, so the rest of it costs a compare. It /// is positional, so every frame forgets it: the cells change under it. memo: Memo = .{}, const max_cells = 1 << 17; const CellKey = packed struct(u64) { cp: u21, role: pardes.FontRole, decoration: GlyphDecoration, _: u30 = 0 }; const shape_slot: Slot = .{ .u = std.math.maxInt(u32), .v = std.math.maxInt(u32) }; const Word = struct { start: u32, len: u32, decoration: GlyphDecoration }; const Memo = struct { line: ?[*]const pardes.Cell = null, len: usize = 0, start: usize = 0, end: usize = 0, cursor: ?usize = null, slots: usize = 0, }; fn clear(w: *Words) void { w.cells.clearRetainingCapacity(); w.map.clearRetainingCapacity(); w.text.clearRetainingCapacity(); w.slots.clearRetainingCapacity(); w.memo = .{}; } fn deinit(w: *Words, gpa: std.mem.Allocator) void { w.cells.deinit(gpa); w.map.deinit(gpa); w.text.deinit(gpa); w.slots.deinit(gpa); w.shaped.deinit(gpa); } }; /// What decides where a word ends, for one cell. A concealed cell is a space. const WordCell = struct { cp: u32, decoration: GlyphDecoration, role: pardes.FontRole }; fn wordCell(cell: *const pardes.Cell) WordCell { return .{ .cp = if (!cell.default and cell.style.invisible) ' ' else cellCodepoint(cell), .decoration = GlyphDecoration.fromCell(cell), .role = cellFontRole(cell), }; } /// Whether a word ends between `a`, cell `i`, and `b`, cell `i + 1`: at a /// space, a decoration or role change, either side of the cursor (Ghostty's /// rule: the cell being edited shows its own glyph, so a ligature under the /// cursor comes apart), and inside f|i, f|l and s|t, whose typographic /// ligatures do not belong on a grid (Ghostty again). Colours never end a /// word: a ligature spans a syntax colour change or a selection edge, and each /// cell tints its own slice. fn wordBreak(a: WordCell, b: WordCell, i: usize, cursor: ?usize) bool { if (a.cp == ' ' or b.cp == ' ') return true; if (cursor) |at| if (at == i or at == i + 1) return true; if (a.role != b.role or !std.meta.eql(a.decoration, b.decoration)) return true; return (a.cp == 'f' and (b.cp == 'i' or b.cp == 'l')) or (a.cp == 's' and b.cp == 't'); } /// The atlas slot `line[at]` draws: blank for a plain space (or concealed /// text), its own glyph when no ligature could replace that glyph (every /// cell, in a font without ligatures or with Ligatures off), else its slice /// of the word around it, shaped once and cached by the word's text. The /// cells of a word being walked cost a compare. Taglines are not shaped: they are drawn at two pitches /// (tagline_width in a tag layer, a whole cell in the grid), and a strip would /// not line up in one of them. fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole) Slot { const words = &g.words; const memo = words.memo; if (memo.line == line.ptr and memo.len == line.len and memo.cursor == cursor and at >= memo.start and at < memo.end and role == .body) return words.slots.items[memo.slots + at - memo.start]; var here = wordCell(&line[at]); // An underlined or struck-out space is a glyph of its own: it draws its line. if (here.cp == ' ' and !here.decoration.drawsBlank()) return g.space_slot; const gpa = g.glyphs.allocator; const cell_key: Words.CellKey = .{ .cp = @intCast(here.cp), .role = role, .decoration = here.decoration }; const known = words.cells.getOrPut(gpa, cell_key) catch return g.space_slot; if (!known.found_existing) { const drawn = glyphForCodepoint(g, here.cp); const font = if (drawn.face == 0) g.font else g.fallbacks[drawn.face - 1].?.face; known.value_ptr.* = if (role == .body and words.ligatures and c.ui_font_substitutes(font, drawn.glyph) != 0) Words.shape_slot else cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = role, .decoration = here.decoration }) orelse g.space_slot; } const plain = known.value_ptr.*; if (!std.meta.eql(plain, Words.shape_slot)) return plain; if (words.text.items.len > Words.max_cells) words.clear(); var start = at; while (start > 0) { const before = wordCell(&line[start - 1]); if (wordBreak(before, here, start - 1, cursor)) break; start -= 1; here = before; } // The word's text goes on the end of `text`: a new word keeps it there, // a known one gives it back. const text_start = words.text.items.len; var end = start; while (true) { words.text.append(gpa, here.cp) catch return g.space_slot; end += 1; if (end == line.len) break; const next = wordCell(&line[end]); if (wordBreak(here, next, end - 1, cursor)) break; here = next; } const text = words.text.items[text_start..]; const decoration = here.decoration; const hash = std.hash.Wyhash.hash(@as(u5, @bitCast(decoration)), std.mem.sliceAsBytes(text)); if (words.map.get(hash)) |word| { if (word.len == text.len and std.meta.eql(word.decoration, decoration) and std.mem.eql(u32, words.text.items[word.start..][0..word.len], text)) { words.text.shrinkRetainingCapacity(text_start); words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = word.start }; return words.slots.items[word.start + at - start]; } } // A new word (a hash collision takes the map entry over). Runs of one // face are shaped in that face; fallback faces shape like the primary. const n = text.len; words.slots.ensureUnusedCapacity(gpa, n) catch { words.text.shrinkRetainingCapacity(text_start); return g.space_slot; }; words.shaped.resize(gpa, n) catch { words.text.shrinkRetainingCapacity(text_start); return g.space_slot; }; const shaped = words.shaped.items; var run_start: usize = 0; var face = glyphForCodepoint(g, text[0]).face; while (run_start < n) { var run_end = run_start + 1; var next_face = face; while (run_end < n) : (run_end += 1) { next_face = glyphForCodepoint(g, text[run_end]).face; if (next_face != face) break; } const run_font = if (face == 0) g.font else g.fallbacks[face - 1].?.face; c.ui_font_shape(run_font, g.px, @intCast(g.cell_w), text[run_start..].ptr, @intCast(run_end - run_start), shaped[run_start..].ptr); for (shaped[run_start..run_end]) |cell| { const key: GlyphKey = .{ .face = face, .glyph = cell.glyph, .role = .body, .decoration = decoration, .lead = @intCast(cell.lead), .span = @intCast(cell.span) }; const slot: Slot = if (cachedGlyph(g, key)) |strip| .{ .u = strip.u + g.cell_w * cell.slice, .v = strip.v } else g.space_slot; words.slots.appendAssumeCapacity(slot); } run_start = run_end; face = next_face; } words.map.put(gpa, hash, .{ .start = @intCast(text_start), .len = @intCast(n), .decoration = decoration }) catch {}; words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = text_start }; return words.slots.items[text_start + at - start]; } /// A Gui that can shape and rasterize `font` at `px`, and nothing else. From /// here on the fonts allocate from std.testing.allocator (the caller puts /// font_allocator back), so the test counts what they leak. fn textTestGui(font: *c.UIFont, px: f32) !Gui { c.ui_font_prime(font); font_allocator = std.testing.allocator; var g: Gui = undefined; g.font = font; g.fallback_count = 0; g.px = px; var cw: c_int = 0; var ch: c_int = 0; c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent); g.cell_w = @intCast(cw); g.cell_h = @intCast(ch); const tag = taglineRaster(font, px, g.cell_w, g.cell_h, 82); g.tagline_px = tag.px; g.tagline_width = tag.width; g.tagline_height = tag.height; g.tagline_baseline = tag.baseline; g.atlas_stage = try std.testing.allocator.alloc(u8, atlas_w * atlas_h); g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); g.words = .{}; resetGlyphAtlas(&g); return g; } fn textTestLine(comptime text: []const u8) [text.len]pardes.Cell { var line: [text.len]pardes.Cell = @splat(.{ .default = false }); for (&line, text) |*cell, byte| cell.text[0] = byte; return line; } test "a ligature draws across its cells, one slice each, and comes apart under the cursor" { const bytes = try filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf"); defer std.testing.allocator.free(bytes); const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(font); var g = try textTestGui(font, 27); defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); const line = textTestLine("a->b"); var slots: [4]Slot = undefined; for (&slots, 0..) |*slot, i| slot.* = cellSlot(&g, &line, i, null, .body); // `->` is one glyph strip two cells wide: each cell samples its own half. try std.testing.expectEqual(slots[1].v, slots[2].v); try std.testing.expectEqual(slots[1].u + g.cell_w, slots[2].u); var strips: usize = 0; var it = g.glyphs.iterator(); while (it.next()) |entry| if (entry.key_ptr.span == 2) { strips += 1; try std.testing.expectEqual(@as(u4, 1), entry.key_ptr.lead); try std.testing.expectEqual(slots[1], entry.value_ptr.*); }; try std.testing.expectEqual(@as(usize, 1), strips); // The arrow runs through the seam: both halves carry ink at it. const seam = g.atlas_stage[@as(usize, slots[2].v) * atlas_w ..]; var left_ink: usize = 0; var right_ink: usize = 0; for (0..g.cell_h) |y| { left_ink += seam[y * atlas_w + slots[2].u - 1]; right_ink += seam[y * atlas_w + slots[2].u]; } try std.testing.expect(left_ink > 0 and right_ink > 0); // `a` and `b` are their plain glyphs. try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'a'), .role = .body }).?, slots[0]); try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'b'), .role = .body }).?, slots[3]); // A second line with the same word reuses the shaped slots. const again = textTestLine("a->b"); const text_len = g.words.text.items.len; for (0..4) |i| try std.testing.expectEqual(slots[i], cellSlot(&g, &again, i, null, .body)); try std.testing.expectEqual(text_len, g.words.text.items.len); // The cursor on either half shows plain `-` and `>`. const plain_minus = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '-'), .role = .body }).?; const plain_greater = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '>'), .role = .body }).?; for ([_]usize{ 1, 2 }) |cursor| { try std.testing.expectEqual(plain_minus, cellSlot(&g, &line, 1, cursor, .body)); try std.testing.expectEqual(plain_greater, cellSlot(&g, &line, 2, cursor, .body)); } // A tagline is never shaped. try std.testing.expectEqual(c.ui_font_glyph(font, '-'), (try glyphKeyAt(&g, cellSlot(&g, &line, 1, null, .tagline))).glyph); } test "Ligatures off draws a ligature as its cells' own glyphs, and on shapes it again" { const bytes = try filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf"); defer std.testing.allocator.free(bytes); const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(font); var g = try textTestGui(font, 27); defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer core.deinit(); const text = "a->b"; const line = textTestLine(text); var shaped: [text.len]Slot = undefined; for (&shaped, 0..) |*slot, i| slot.* = cellSlot(&g, &line, i, null, .body); try std.testing.expectEqual(shaped[1].u + g.cell_w, shaped[2].u); // Off, every cell is the slot the per-glyph path gives its codepoint. try std.testing.expect(core.executeBuiltinLine(core.active, "Ligatures off")); syncLigatures(&g, core); for (text, 0..) |byte, i| { const plain = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, byte), .role = .body }).?; try std.testing.expectEqual(plain, cellSlot(&g, &line, i, null, .body)); } try std.testing.expect(!std.meta.eql(shaped[1], cellSlot(&g, &line, 1, null, .body))); // On again, the arrow is its strip once more. try std.testing.expect(core.executeBuiltinLine(core.active, "Ligatures on")); syncLigatures(&g, core); for (shaped, 0..) |slot, i| try std.testing.expectEqual(slot, cellSlot(&g, &line, i, null, .body)); } fn glyphKeyAt(g: *Gui, slot: Slot) !GlyphKey { var it = g.glyphs.iterator(); while (it.next()) |entry| if (std.meta.eql(entry.value_ptr.*, slot)) return entry.key_ptr.*; return error.NoGlyph; } test "a font without ligatures draws every cell as its own glyph, exactly as before shaping" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var g = try textTestGui(font, 18); defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); const text = "a->b != c => d <= e |> www == :: 0x1F"; var line = textTestLine(text); line[3].style.bold = true; // a style change inside a word var expected: [64 * 64]u8 = undefined; for (line, 0..) |cell, i| { if (text[i] == ' ') continue; const slot = cellSlot(&g, &line, i, null, .body); const key = try glyphKeyAt(&g, slot); try std.testing.expectEqual(c.ui_font_glyph(font, text[i]), key.glyph); try std.testing.expectEqual(@as(u4, 1), key.span); _ = c.ui_font_raster(font, 18, key.glyph, @intFromBool(cell.style.bold), &expected, 64, @intCast(g.cell_w), @intCast(g.cell_h), 0, @intCast(g.cell_w), g.ascent); for (0..g.cell_h) |y| try std.testing.expectEqualSlices(u8, expected[y * 64 ..][0..g.cell_w], g.atlas_stage[(slot.v + y) * atlas_w + slot.u ..][0..g.cell_w]); } } /// The atlas slot of `key`, rasterized on first use. A strip is `span` cells /// wide (a grip glyph GUTTER cells, centered); a cell samples its slice of it. /// Null when the atlas is full. fn cachedGlyph(g: *Gui, key: GlyphKey) ?Slot { if (g.glyphs.get(key)) |s| return s; const slot_w = g.cell_w * @as(u32, if (key.centered) config.GUTTER else key.span); if (g.pen_x + slot_w > atlas_w) { g.pen_x = 0; g.pen_y += g.cell_h; } if (g.pen_y + g.cell_h > atlas_h) return null; // atlas full: fall back const s = Slot{ .u = g.pen_x, .v = g.pen_y }; const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; const face = if (key.face == 0) g.font else g.fallbacks[key.face - 1].?.face; const px = if (key.role == .tagline) g.tagline_px else g.px; const baseline = if (key.role == .tagline) g.tagline_baseline else g.ascent; const cell_w = if (key.role == .tagline) g.tagline_width else g.cell_w; const raster_w = if (key.centered) slot_w else cell_w * key.span; const raster_h = if (key.role == .tagline) g.tagline_height else g.cell_h; const bold = @intFromBool(key.decoration.bold); if (key.centered) { _ = c.ui_font_raster_centered(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h)); } else { _ = c.ui_font_raster(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), @intCast(cell_w * key.lead), @intCast(cell_w), baseline); } decorateLine(out, atlas_w, raster_w, raster_h, baseline, key.decoration); if (key.centered) centerGlyphInk(out, raster_w, raster_h); g.atlas_dirty_top = @min(g.atlas_dirty_top, s.v); g.atlas_dirty_bottom = @max(g.atlas_dirty_bottom, s.v + raster_h); g.glyphs.put(key, s) catch return null; g.pen_x += slot_w; return s; } // Run once when a grip glyph enters the atlas, after decorations. Copy only // visible ink so bearings, empty bitmap margins and bold all share one center. fn centerGlyphInk(out: [*]u8, width: usize, height: usize) void { const b = inkBounds(out[0 .. (height - 1) * atlas_w + width], atlas_w, width, height) orelse return; const w = b.max_x - b.min_x + 1; const h = b.max_y - b.min_y + 1; const left = (width - w) / 2; const top = (height - h) / 2; var scratch: [atlas_w]u8 = undefined; for (0..h) |step| { const row = if (top > b.min_y) h - 1 - step else step; const source = (b.min_y + row) * atlas_w + b.min_x; const target = (top + row) * atlas_w; @memcpy(scratch[0..w], out[source..][0..w]); @memset(out[target..][0..width], 0); @memcpy(out[target + left ..][0..w], scratch[0..w]); } for (0..height) |row| { if (row < top or row >= top + h) @memset(out[row * atlas_w ..][0..width], 0); } } test "pane indicator cached ink centers bearings styles and sizes" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var g: Gui = undefined; g.font = font; g.fallback_count = 0; g.atlas_stage = try std.testing.allocator.alloc(u8, atlas_w * atlas_h); defer std.testing.allocator.free(g.atlas_stage); g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer g.glyphs.deinit(); g.words = .{}; for ([_]f32{ 13, 27, 40 }) |px| { g.px = px; var cw: c_int = 0; var ch: c_int = 0; c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent); g.cell_w = @intCast(cw); g.cell_h = @intCast(ch); for ([_]u8{ 40, 82, 100 }) |percent| { const tag = taglineRaster(font, px, g.cell_w, g.cell_h, percent); g.tagline_width = tag.width; g.tagline_height = tag.height; g.tagline_px = tag.px; g.tagline_baseline = tag.baseline; resetGlyphAtlas(&g); try std.testing.expectEqual(@as(usize, 0), g.glyphs.count()); for ([_]u32{ '^', '$', 'j' }) |cp| for ([_]bool{ false, true }) |bold| { const key: GlyphKey = .{ .face = 0, .glyph = c.ui_font_glyph(font, @intCast(cp)), .role = .tagline, .decoration = .{ .bold = bold }, .centered = true }; const slot = cachedGlyph(&g, key).?; const bounds = inkBounds(g.atlas_stage[@as(usize, slot.v) * atlas_w + slot.u ..], atlas_w, g.cell_w * config.GUTTER, tag.height).?; const center_x: isize = @intCast(bounds.min_x + bounds.max_x); const center_y: isize = @intCast(bounds.min_y + bounds.max_y); try std.testing.expect(@abs(center_x - @as(isize, @intCast(g.cell_w * config.GUTTER - 1))) <= 1); try std.testing.expect(@abs(center_y - @as(isize, @intCast(tag.height - 1))) <= 1); const count = g.glyphs.count(); const pen = g.pen_x; try std.testing.expectEqualDeep(slot, cachedGlyph(&g, key).?); try std.testing.expectEqual(count, g.glyphs.count()); try std.testing.expectEqual(pen, g.pen_x); }; } } resetGlyphAtlas(&g); g.pen_y = atlas_h; try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '^'), .role = .tagline, .centered = true })); } test "pane indicator halves share dissolve changes and safe exhausted atlas" { var cells: [12]pardes.Cell = @splat(.{ .default = false, .style = .{ .font_role = .tagline } }); cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); const surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs }; // A grip two cells wide at the top left, in both frames. const grip = [_]pardes.Region{.{ .kind = .grip, .serial = 1, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}; var cover: [12]Cover = undefined; var previous_cover: [12]Cover = undefined; coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); coverFrame(&previous_cover, 6, 2, &.{}, &.{}, &grip); try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 0, 0)); try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); try std.testing.expect(!cover[2].grip); // A column's grip is a button too, cut the same way (it has no glyph). coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); try std.testing.expect(cover[0].grip); coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); const chrome: pardes.Chrome = .{}; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var g: Gui = undefined; g.font = font; g.fallback_count = 0; g.px = 20; g.tagline_px = 16; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; g.tagline_height = 16; g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = 0; g.pen_y = atlas_h; g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer g.glyphs.deinit(); g.words = .{}; defer g.words.deinit(std.testing.allocator); for ([_]bool{ false, true }) |old| { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { emitSurfaceCell(&g, &chrome, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], cover[0..6], col, null, old, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0); try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0); } try std.testing.expectEqual(@as(u32, 4), next); } } // Underline and strikethrough stay inside the existing atlas slot, so text // advance, caret and selection geometry are identical to the regular glyph. // The cached mask keeps this work out of steady-state rendering and also // decorates terminal spaces. Bold is not drawn here: the rasterizer emboldens // the outline. fn decorateLine(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void { if (width == 0 or height == 0) return; // A line is a logical pixel thick, its dots and waves as long: the same // weight at any density (dp). const t: usize = @intFromFloat(dp(1)); if (decoration.underline != .off) { const base: usize = @intCast(@max(0, baseline)); const row = @min(base + 1, height -| t); for (0..width) |x| { const top = switch (decoration.underline) { .off => unreachable, .single => row, .double => { const upper = @min(base + 1, height -| 3 * t); for (0..t) |k| { out[@min(upper + k, height - 1) * stride + x] = 255; out[@min(upper + 2 * t + k, height - 1) * stride + x] = 255; } continue; }, .curly => row -| @as(usize, if (x % (4 * t) < 2 * t) t else 0), .dotted => if (x % (3 * t) < t) row else continue, .dashed => if (x % (6 * t) < 4 * t) row else continue, }; for (0..t) |k| out[@min(top + k, height - 1) * stride + x] = 255; } } if (decoration.strikethrough) { const row = @min(height -| t, @as(usize, @intCast(@max(0, baseline))) * 2 / 3); for (0..t) |k| @memset(out[@min(row + k, height - 1) * stride ..][0..width], 255); } } test "glyph decorations render spaces and stay inside their atlas slot" { const stride = 9; const width = 5; const height = 7; for ([_]GlyphDecoration{ .{ .underline = .single }, .{ .underline = .double }, .{ .underline = .curly }, .{ .underline = .dotted }, .{ .underline = .dashed }, .{ .strikethrough = true }, }) |decoration| { var mask: [stride * (height + 1)]u8 = @splat(0xa5); for (0..height) |y| @memset(mask[y * stride ..][0..width], 0); decorateLine(&mask, stride, width, height, 5, decoration); try std.testing.expect(inkBounds(&mask, stride, width, height) != null); for (0..height) |y| for (mask[y * stride + width ..][0 .. stride - width]) |guard| try std.testing.expectEqual(@as(u8, 0xa5), guard); for (mask[stride * height ..]) |guard| try std.testing.expectEqual(@as(u8, 0xa5), guard); } const hidden: pardes.Cell = .{ .default = false, .style = .{ .invisible = true, .ul = .single } }; try std.testing.expect(!GlyphDecoration.fromCell(&hidden).drawsBlank()); } /// Uploads only the atlas rows drawn into since the last upload. Glyphs fill /// the atlas pen-style, a band of rows at a time, so new glyphs are a band a /// cell or two tall, not the whole 4 MB texture. The band sits at the same /// offset in the transfer buffer as in the stage, and the texture keeps the /// rows outside it. fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { const top = g.atlas_dirty_top; const rows = g.atlas_dirty_bottom - top; const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; const dst: [*]u8 = @ptrCast(ptr); @memcpy(dst[top * atlas_w ..][0 .. rows * atlas_w], g.atlas_stage[top * atlas_w ..][0 .. rows * atlas_w]); c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = top * atlas_w, .pixels_per_row = atlas_w, .rows_per_layer = rows }; const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = top, .z = 0, .w = atlas_w, .h = rows, .d = 1 }; c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); c.SDL_EndGPUCopyPass(copy); g.atlas_dirty_top = atlas_h; g.atlas_dirty_bottom = 0; } test "the atlas's dirty rows hold every row a new glyph draws into, and a reset dirties them all" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var g = try textTestGui(font, 18); defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); // The texture as uploadAtlas leaves it, which starts undefined. const texture = try std.testing.allocator.alloc(u8, atlas_w * atlas_h); defer std.testing.allocator.free(texture); @memset(texture, 0xa5); var glyph: u32 = 0; for (0..40) |frame| { if (frame == 30) resetGlyphAtlas(&g); // a font change // Plain glyphs, strips, taglines, grip glyphs and decorations. for (0..25) |_| { glyph += 1; const span: u4 = @intCast(1 + glyph % 3); _ = cachedGlyph(&g, .{ .face = 0, .glyph = glyph, .role = if (glyph % 4 == 0) .tagline else .body, .decoration = .{ .bold = glyph % 2 == 0, .underline = if (glyph % 5 == 0) .curly else .off, .strikethrough = glyph % 11 == 0 }, .centered = glyph % 13 == 0, .lead = @intCast(glyph % span), .span = span, }) orelse return error.AtlasFull; } for (0..atlas_h) |row| { const stage = g.atlas_stage[row * atlas_w ..][0..atlas_w]; const uploaded = texture[row * atlas_w ..][0..atlas_w]; if (row >= g.atlas_dirty_top and row < g.atlas_dirty_bottom) @memcpy(uploaded, stage) else try std.testing.expect(std.mem.eql(u8, stage, uploaded)); } g.atlas_dirty_top = atlas_h; g.atlas_dirty_bottom = 0; } try std.testing.expect(g.pen_y >= 2 * g.cell_h); } fn ensureVbuf(g: *Gui, cells: u32) !void { if (cells <= g.vbuf_cells and g.vbuf != null and g.vxfer != null) return; var capacity = @max(@as(u32, 1), g.vbuf_cells); while (capacity < cells) capacity = std.math.mul(u32, capacity, 2) catch return error.GpuCapacity; const size = std.math.mul( u32, capacity, @as(u32, @intCast(@sizeOf(CellInstance))), ) catch return error.GpuCapacity; var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; const next_vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUBuffer(g.device, next_vbuf); var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; const next_vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(g.device, buffer); if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.vbuf = next_vbuf; g.vxfer = next_vxfer; g.vbuf_cells = capacity; } /// A pipeline over cell instances: the cells' own (ui.frag, the atlas, no /// blending) or the decor's (decor.frag, blended like the overlay). fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, blend: c.SDL_GPUColorTargetBlendState) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(CellInstance), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") }, .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") }, .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") }, .{ .location = 7, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "clip_x0") }, }; var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state = .{ .vertex_buffer_descriptions = &vbuf_desc, .num_vertex_buffers = 1, .vertex_attributes = &attrs, .num_vertex_attributes = attrs.len, }; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } /// Premultiplied over: an opaque cell writes itself exactly, a see-through /// one only its ink. fn premultipliedBlend() c.SDL_GPUColorTargetBlendState { var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.enable_blend = true; return blend; } fn backgroundLayerBlend() c.SDL_GPUColorTargetBlendState { var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_CONSTANT_COLOR; blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.enable_blend = true; return blend; } test "overlapping background layers keep one uniform opacity" { const blend = backgroundLayerBlend(); try std.testing.expectEqual(@as(c.SDL_GPUBlendFactor, c.SDL_GPU_BLENDFACTOR_CONSTANT_COLOR), blend.src_alpha_blendfactor); try std.testing.expectEqual(@as(c.SDL_GPUBlendFactor, c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA), blend.dst_alpha_blendfactor); for ([_]f32{ 0, 0.25, 0.7, 1 }) |opacity| { var alpha = opacity; for ([_]f32{ 1, 0.5, 0.88, 0, 1 }) |coverage| { alpha = opacity * coverage + alpha * (1 - coverage); try std.testing.expectApproxEqAbs(opacity, alpha, 0.00001); } } } fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(OverlayVertex), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, }; const blend = backgroundLayerBlend(); var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state = .{ .vertex_buffer_descriptions = &vbuf_desc, .num_vertex_buffers = 1, .vertex_attributes = &attrs, .num_vertex_attributes = attrs.len, }; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(ImageInstance), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "x0") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") }, .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "panel_x0") }, .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "present_x0") }, .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(ImageInstance, "effect") }, }; const blend = backgroundLayerBlend(); var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state.vertex_buffer_descriptions = &vbuf_desc; info.vertex_input_state.num_vertex_buffers = 1; info.vertex_input_state.vertex_attributes = &attrs; info.vertex_input_state.num_vertex_attributes = attrs.len; info.target_info.color_target_descriptions = &col_desc; info.target_info.num_color_targets = 1; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } pub fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader { var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); info.code = code.ptr; info.code_size = code.len; info.entrypoint = "main"; info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; info.stage = stage; info.num_samplers = n_samplers; info.num_uniform_buffers = n_uniform_buffers; return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; if (g.scene_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture); g.scene_tex = next; g.scene_tex_w = width; g.scene_tex_h = height; } fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; if (g.capture_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture); g.capture_tex = next; g.capture_tex_w = width; g.capture_tex_h = height; } fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { const candidates = [_]c.SDL_GPUTextureFormat{ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, }; for (candidates) |format| { if (c.SDL_GPUTextureSupportsFormat( device, format, c.SDL_GPU_TEXTURETYPE_2D, c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER, )) return format; } return c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; } fn softPixelFormat(format: c.SDL_GPUTextureFormat) ?c.SDL_PixelFormat { return switch (format) { c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB, => c.SDL_PIXELFORMAT_ARGB8888, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB, => c.SDL_PIXELFORMAT_ABGR8888, else => null, }; } fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { if (g.soft_texture) |texture| { if (g.soft_tex_w == width and g.soft_tex_h == height) return texture; c.SDL_DestroyTexture(texture); g.soft_texture = null; } const renderer = g.soft_renderer orelse return error.GpuCreate; const pixel_format = softPixelFormat(g.swapchain_format) orelse return error.UnsupportedCaptureFormat; const next = c.SDL_CreateTexture( renderer, pixel_format, c.SDL_TEXTUREACCESS_STREAMING, @intCast(width), @intCast(height), ) orelse return error.GpuCreate; _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); _ = c.SDL_SetTextureBlendMode(next, c.SDL_BLENDMODE_NONE); g.soft_texture = next; g.soft_tex_w = width; g.soft_tex_h = height; return next; } fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0 or bpp != 4) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.GpuCreate; }; if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.capture_xfer = next; g.capture_xfer_size = size; } const xfer = g.capture_xfer.?; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const texture = try ensureSoftTexture(g, sw, sh); const renderer = g.soft_renderer orelse return error.GpuCreate; if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; // The cell shader already produced premultiplied backgrounds with opaque // foreground ink. Copy that alpha unchanged; never modulate the full frame. _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); _ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, if (g.transparent or g.surface_opacity) 0 else 255); _ = c.SDL_RenderClear(renderer); _ = c.SDL_RenderTexture(renderer, texture, null, null); _ = c.SDL_RenderPresent(renderer); } fn backgroundOpacity(percent: u8) f32 { std.debug.assert(percent <= 100); return @as(f32, @floatFromInt(percent)) / 100; } test "background opacity retains exact endpoints and percentage precision" { try std.testing.expectEqual(@as(f32, 0), backgroundOpacity(0)); try std.testing.expectEqual(@as(f32, 0.7), backgroundOpacity(70)); try std.testing.expectEqual(@as(f32, 1), backgroundOpacity(100)); for (0..100) |percent| { try std.testing.expect(backgroundOpacity(@intCast(percent)) < backgroundOpacity(@intCast(percent + 1))); } } /// PARDES_TEST_LATENCY trace for an external driver, CLOCK_MONOTONIC ns: /// `R t` when stdin input is read, `F start fence submit` per captured frame /// (present entry, the GPU fence of that frame, and its submission: submit - /// start is the frame's CPU cost). Nothing is written without it. var latency_fd: c_int = -1; var frame_started_ns: u64 = 0; fn monoNs() u64 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); } fn latencyNote(comptime fmt: []const u8, args: anytype) void { if (latency_fd < 0) return; var buf: [64]u8 = undefined; const line = std.fmt.bufPrint(&buf, fmt, args) catch return; _ = libc.write(latency_fd, line.ptr, line.len); } /// Writes the frame to latest.ppm in the capture directory, and a panel /// transition's frame `transition` to transition-.ppm too, so a test /// can hold one partway (test/gui_golden.py). fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32, transition: ?u16) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.GpuCreate; }; if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.capture_xfer = next; g.capture_xfer_size = size; } const xfer = g.capture_xfer.?; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); const submit_ns = monoNs(); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; latencyNote("F {d} {d} {d}\n", .{ frame_started_ns, monoNs(), submit_ns }); const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const pixels: [*]const u8 = @ptrCast(mapped); try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); var name: [24]u8 = undefined; if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable); g.captured +%= 1; } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { // A latency trace times frames, and an 8 MB file write per frame would // stall the loop it is timing. if (g.capture_dir.len == 0 or latency_fd >= 0) return; const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); if (bpp != 4) return error.UnsupportedCaptureFormat; const bgr = switch (g.swapchain_format) { c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, else => return error.UnsupportedCaptureFormat, }; var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/{s}.ppm.tmp", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/{s}.ppm", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.CaptureWriteFailed; defer _ = libc.close(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; _ = host_io.writeFd(fd, hdr); const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); defer gpa.free(row_rgb); for (0..height) |row| { const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; for (0..width) |x| { const si = x * 4; const di = x * 3; row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; row_rgb[di + 1] = src[si + 1]; row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; } _ = host_io.writeFd(fd, row_rgb); } if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } /// One flat, hard-edged rectangle of decor, in window pixels: clipped to the /// window and dropped when empty, as the triangle overlay drew it, and moved /// by `track` as its pane's cells are. fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, rgb: [3]u8, alpha: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, win_w); const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, win_w); const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, win_h); const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, win_h); if (x1 <= x0 or y1 <= y0) return; // decor.frag.glsl reads the colour from the ground and the coverage // from the ink's red. var instance: CellInstance = .{ .x0 = (x0 / win_w) * 2.0 - 1.0, .y0 = 1.0 - (y0 / win_h) * 2.0, .x1 = (x1 / win_w) * 2.0 - 1.0, .y1 = 1.0 - (y1 / win_h) * 2.0, .u0 = 0, .v0 = 0, .u1 = 0, .v1 = 0, .fr = alpha, .fg = 0, .fb = 0, .br = @as(f32, @floatFromInt(rgb[0])) / 255.0, .bg = @as(f32, @floatFromInt(rgb[1])) / 255.0, .bb = @as(f32, @floatFromInt(rgb[2])) / 255.0, }; setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); try g.decor.append(gpa, instance); } /// A checker (decor.frag.glsl): squares of `px` window pixels in `a` and /// `b` from the quad's corner (lapis's scroll track); `stripes`, bands of /// `px` across it instead (lapis's focused title bar), `a` first. fn addChecker(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { return addPattern(g, gpa, x0, y0, x1, y1, a, b, px, false, track, win_w, win_h); } fn addPattern(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, stripes: bool, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { const first = g.decor.items.len; try addDecor(g, gpa, x0, y0, x1, y1, a, 1, track, win_w, win_h); if (g.decor.items.len == first) return; const instance = &g.decor.items[first]; instance.u0 = px; instance.u1 = px; instance.v0 = if (stripes) 1 else 0; instance.v1 = if (stripes) 1 else 0; instance.fr = @as(f32, @floatFromInt(b[0])) / 255.0; instance.fg = @as(f32, @floatFromInt(b[1])) / 255.0; instance.fb = @as(f32, @floatFromInt(b[2])) / 255.0; instance.effect |= checker_bit; } /// How a lift of 0 to 1 stands off the page: the shadow's blur and offset /// in cell heights, and its darkest, where it leaves the caster's edge. /// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge. const Shadow = struct { // A key light above: the drop is most of the blur, so the shadow hangs // below and to the right and barely reaches over the caster's top. const sigma: f32 = 0.42; const drop: f32 = 0.34; const side: f32 = 0.1; const strength: f32 = 0.42; }; /// A soft rectangle around `caster` (window pixels), blurred by `sigma` /// and moved by (dx, dy): black is a shadow, a light colour a rim or a /// glow. The caster's own rectangle is left alone, and `track` moves it all /// as its caster moves. fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f32, dy: f32, strength: f32, rgb: [3]u8, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (strength <= 0.002) return; const sigma = @max(0.5, sigma_in); const x0 = caster[0] + dx - 3 * sigma; const y0 = caster[1] + dy - 3 * sigma; const x1 = caster[2] + dx + 3 * sigma; const y1 = caster[3] + dy + 3 * sigma; const w = x1 - x0; const ht = y1 - y0; if (w <= 0 or ht <= 0) return; // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the // caster's offset from its shadow, the strength. var instance: CellInstance = .{ .x0 = (x0 / win_w) * 2.0 - 1.0, .y0 = 1.0 - (y0 / win_h) * 2.0, .x1 = (x1 / win_w) * 2.0 - 1.0, .y1 = 1.0 - (y1 / win_h) * 2.0, .u0 = sigma / w, .v0 = sigma / ht, .u1 = sigma / w, .v1 = sigma / ht, .fr = strength, .fg = dx / w, .fb = dy / ht, .br = @as(f32, @floatFromInt(rgb[0])) / 255, .bg = @as(f32, @floatFromInt(rgb[1])) / 255, .bb = @as(f32, @floatFromInt(rgb[2])) / 255, }; setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); instance.effect |= soft_shadow_bit; try g.decor.append(gpa, instance); } /// How far each lift style may go on this theme without its text falling /// below min(its contrast, 4.5): a shadow darkens, the others lighten. const LiftCeilings = struct { shadow: f32, white: f32, /// A rim is light on a dark page and shade on a light one, where light /// would not show. rim: [3]u8, /// Over the text on the page, and over a selection's text on its own /// ground: the lower of the two. fn of(chrome: *const pardes.Chrome) LiftCeilings { const fg = chrome.fg orelse fg_default; const bg = chrome.page orelse bg_default; return .{ .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, }; } }; /// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, /// a notice's 1), in the style chosen: a soft shadow, or a rim along the /// top of the focused pane's tag rows (`rim_edge`, a pane's only: /// never a notice's). fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (lift <= 0.001) return; const h: f32 = @floatFromInt(g.cell_h); // The core sends `auto` already resolved (draw.liftStyle). const shadow = switch (style) { .off => return, .shadow, .auto => true, .rim => false, }; if (shadow) { // Nothing of a shadow may touch a focus indicator (docs/effects.md): // it falls on pane bodies and rails, never a tag, a grip or a header. const first = g.decor.items.len; try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); try clipToBodies(g, gpa, first, bodies); } // A hairline just above the focused tag rows, never on them: it adds to // the indicator without changing its colour. if (style == .rim) if (rim_edge) |edge| { const light = ceilings.rim[0] == 255; const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); try addDecor(g, gpa, edge[0], edge[2] - dp(1), edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); }; } /// A rim's strength, in the §8.2 budget for coloured light. const Lift = struct { const rim: f32 = 0.35; }; /// A body region in window pixels. fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { return .{ @as(f32, @floatFromInt(region.rect.x)) * layout.w, @as(f32, @floatFromInt(region.rect.y)) * layout.h, @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, }; } /// Replaces the decor from `first` on with a copy per pane body it reaches, /// each clipped to that body (and to what a vertical transition already /// clipped it to): the lift then never reaches a tag, a grip or a header. fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { const added = g.decor.items.len - first; if (added == 0) return; var own: [8]CellInstance = undefined; const n = @min(added, own.len); @memcpy(own[0..n], g.decor.items[first..][0..n]); g.decor.shrinkRetainingCapacity(first); for (own[0..n]) |instance| for (bodies) |body| { var copy = instance; const vertical = copy.clip_x1 >= copy.clip_x0; copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; try g.decor.append(gpa, copy); }; } /// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as /// the decor pipeline blends a colour that is not black): the largest `a` /// that keeps text and page apart by min(their contrast, 4.5). fn lightenCeiling(fg: [3]u8, bg: [3]u8, rgb: [3]u8) f32 { const contrast = struct { fn of(a: [3]u8, b: [3]u8) f32 { const la = luminance(a); const lb = luminance(b); return (@max(la, lb) + 0.05) / (@min(la, lb) + 0.05); } fn blend(under: [3]u8, over: [3]u8, a: f32) [3]u8 { var out: [3]u8 = undefined; for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - a) + @as(f32, @floatFromInt(y)) * a)); return out; } }; const target = @min(contrast.of(fg, bg), 4.5); var lo: f32 = 0; var hi: f32 = 1; for (0..24) |_| { const mid = (lo + hi) / 2; if (contrast.of(contrast.blend(fg, rgb, mid), contrast.blend(bg, rgb, mid)) >= target - 1e-3) lo = mid else hi = mid; } return lo; } /// sRGB's relative luminance (WCAG). fn luminance(rgb: [3]u8) f32 { var sum: f32 = 0; for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { const v = @as(f32, @floatFromInt(channel)) / 255; sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); } return sum; } /// How dark a shadow may make the page and its text, both together, before /// the pair drops below its own contrast or 4.5, whichever is lower (§8.2): /// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05). fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { const a = luminance(fg); const b = luminance(bg); const light = @max(a, b); const dark = @min(a, b); const target = @min((light + 0.05) / (dark + 0.05), 4.5); const room = light - target * dark; if (room <= 0) return 0; return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); } test "a rim never lightens a native theme's text below its contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; for ([_][3]u8{.{ 255, 255, 255 }}) |light| { const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); const blend = struct { fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { var out: [3]u8 = undefined; for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - t) + @as(f32, @floatFromInt(y)) * t)); return out; } }.of; const ratio = struct { fn of(x: [3]u8, y: [3]u8) f32 { return (@max(luminance(x), luminance(y)) + 0.05) / (@min(luminance(x), luminance(y)) + 0.05); } }.of; try std.testing.expect(ratio(blend(fg, light, a), blend(bg, light, a)) >= @min(ratio(fg, bg), 4.5) - 2e-3); } } } test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { const gpa = std.testing.allocator; for ([_][]const u8{ "forge", "daybreak" }) |name| { const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); var line: [64]u8 = undefined; try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); for (0..40) |_| core.update(.tick); for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { core.settings.lift = style; try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); core.advance(core.now_ns + 2 * std.time.ns_per_s); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); core.advance(core.now_ns + 2 * std.time.ns_per_s); const settled = try core.render(core.scratch.allocator()); _ = s; // The focus indicator itself: the focused pane's tag is not the // others' colour, whatever the lift. var active: ?[3]u8 = null; var other: ?[3]u8 = null; for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { if (layer.id == core.active) active = layer.bg else other = layer.bg; }; try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); var g: Gui = undefined; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; g.decor = .empty; defer g.decor.deinit(gpa); var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); var lifted: usize = 0; for (g.decor.items) |item| { if (item.clip_x1 < item.clip_x0) continue; lifted += 1; // Inside a body or a rail, all of it. const inside = for (settled.regionList()) |region| { if (region.kind != .body and region.kind != .rail) continue; const r = bodyPixels(region, fixedCellLayout(&g)); if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; } else false; try std.testing.expect(inside); } const light_page = std.mem.eql(u8, name, "daybreak"); try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); // A rim is one hairline, just above the focused pane's tag row. var rims: usize = 0; for (g.decor.items) |item| { if (item.clip_x1 >= item.clip_x0) continue; const top = (1 - item.y0) / 2 * 480; const height = (item.y0 - item.y1) / 2 * 480; const on_active_tag = for (settled.regionList()) |region| { if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; } else false; // Not the rule under a column bar, which may run there too. rims += @intFromBool(on_active_tag and item.fr < 1); } try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); } } } test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; const ceiling = shadowCeiling(fg, bg); const k = 1 - @min(Shadow.strength, ceiling); const light = @max(luminance(fg), luminance(bg)); const dark = @min(luminance(fg), luminance(bg)); const before = (light + 0.05) / (dark + 0.05); const after = (k * light + 0.05) / (k * dark + 0.05); try std.testing.expect(after >= @min(before, 4.5) - 1e-3); shaded += @intFromBool(ceiling > 0.05); } // And most themes have room for one. try std.testing.expect(shaded * 2 > pardes.themes.len); } /// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it /// read from the regions and the palette: the page's rules and the still /// panes' rails, rules and grip marks in tier 0, a moving or closing pane's /// in its track's group, notice rules in tier 4, bar cursors in tier 5. fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, groups: *Groups, layout: CellLayout, win_w: f32, win_h: f32, cursors_shown: bool) !void { g.decor.clearRetainingCapacity(); const chrome = &surface.chrome; const regions = surface.regionList(); const ceilings = LiftCeilings.of(chrome); var current_buf: [pardes.MAX_PANES][4]f32 = undefined; var ncurrent: usize = 0; for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { current_buf[ncurrent] = bodyPixels(part, layout); ncurrent += 1; }; const current_bodies = current_buf[0..ncurrent]; const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; const notice_lift = for (regions) |region| { if (region.kind == .notice) break region.lift; } else 0; for (groups.items[0..groups.len], 0..) |*group, index| { group.decor_start = @intCast(g.decor.items.len); defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; // Under the cells: a theme's tag plaques, this group's panes' (and // the column and workspace tags with the still ones). if (index <= groups.tracks) { const closing = if (group.track) |active| active.phase == .closing else false; try tagPlaques(g, gpa, if (closing) surface.previous_regions else regions, groups, index, chrome, layout, win_w, win_h, group.track); } group.under_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; // The grips' buttons before any rule: the rules over their rows cut // them, and the band round them never covers a rule. if (index <= groups.tracks) { const closing = if (group.track) |active| active.phase == .closing else false; try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track); } if (index == 0) { const top = for (regions) |region| { if (region.kind == .workspace_tag) break region.rect.h; } else 0; // The workspace tag is ruled off from the tags right under it. const tag_under = for (regions) |region| switch (region.kind) { .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, else => {}, } else false; // As wide as the other rules, where the tagline leaves the room. const height: u32 = @intFromFloat(@max(1, @min(rulePx(chrome), @as(f32, @floatFromInt((g.cell_h -| g.tagline_height) * 2))))); if (top != 0 and top < surface.rows and tag_under and height != 0) { const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); } // The column bar is ruled off from the panes under it. for (regions) |bar| { if (bar.kind != .column_grip) continue; const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h); break; } // A tag on the last row runs on through the pixels under the grid. const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; const tag_last = for (regions) |region| switch (region.kind) { .grip, .tag, .column_grip, .column_tag, .workspace_tag => if (region.rect.h != 0 and region.rect.y + region.rect.h == surface.rows) break true, else => {}, } else false; if (grid_bottom < win_h and tag_last) try addDecor(g, gpa, 0, grid_bottom, win_w, win_h, chrome.tag_bg, 1, null, win_w, win_h); } if (index <= groups.tracks) { const track = group.track; // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; // Where a lift may fall: pane bodies, never tags (addLift). var body_buf: [pardes.MAX_PANES][4]f32 = undefined; var nbodies: usize = 0; for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { body_buf[nbodies] = bodyPixels(part, layout); nbodies += 1; }; const bodies = body_buf[0..nbodies]; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; const x = @as(f32, @floatFromInt(column.rect.x)) * layout.w; const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; // None at the window's edge: nothing to rule apart there. if (column.rect.x == 0) continue; if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h); }; // Cast last in the group, over its cells, images and chrome: a // lifted pane's shadow darkens its neighbours' rails and rules // as it darkens their text, and never the pane itself. for (placed) |grip| { if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue; var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h }; box[2] = box[0]; for (placed) |part| { if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue; box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h); box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); } // The top edge of its tag rows, for a rim: x0, x1, y. const rim_edge: ?[3]f32 = for (placed) |part| { if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; } else null; try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is // ruled off from the text beneath it the way a tag is: the same // colour and thickness along its bottom, running to the window's // edge when it does, fading up and dissolving with its band. // A notice floats: its shadow first, then every rule. for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; // A band still falling into place is cut at its row's top; its // rule comes with it, and shows only once it is below that cut. const rule_y = top + (1 + layer.slide) * layout.h; if (rule_y - dp(1) < top) continue; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; try addDecor(g, gpa, x0, rule_y - dp(1), rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h); } if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); } } /// Each pane's rule between its tag and body, and its scroll rail: a page /// coloured gutter with a thin track and its thumb, running down through the /// pixels past the last whole row. Only the panes of group `index`. fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { const page = chrome.page orelse bg_default; for (regions) |rail| { if (rail.kind != .rail or rail.rect.h == 0 or groupOf(groups, rail.serial) != index) continue; const tag = for (regions) |region| { if (region.kind == .tag and region.owner == rail.owner) break region; } else continue; if (@as(u32, tag.rect.x) + tag.rect.w > cols or @as(u32, tag.rect.y) + tag.rect.h > rows or @as(u32, rail.rect.y) + rail.rect.h > rows) continue; const x0 = @as(f32, @floatFromInt(rail.rect.x)) * layout.w; const x1 = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; // Like the band above it, the rule of a pane at the right edge runs // on through the pixels past the last whole cell. Under a tag on // top, over one below its body. const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; const tag_bottom = tag.rect.y > rail.rect.y; const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - dp(1), rule_x1, if (tag_bottom) rule_y + dp(1) else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h); // Stacked panes are ruled apart as columns are, where one pane's // body meets the next one's tag (acme's Border). { const pane_top = @min(tag.rect.y, rail.rect.y); const stacked = for (regions) |other| { if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; } else false; // Inside the tag band's slack, never over its glyphs: the top // slack of a tag on top, the bottom slack of the one above when // tags are at the bottom; a pixel at least. const y = @as(f32, @floatFromInt(pane_top)) * layout.h; const spare = g.cell_h -| g.tagline_height; if (stacked and !tag_bottom) { const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height); try addDecor(g, gpa, x0, y, rule_x1, y + @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(room)))), chrome.border, 1, track, win_w, win_h); } else if (stacked and pane_top > 0) { const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height); try addDecor(g, gpa, x0, y - @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h); } } const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; const body_end = rail.rect.y + rail.rect.h; const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; // A theme's own track fills its width, the thumb one pixel short // of it so the track edges it (acme's scrollbar); else a thin rail. // acme's scrollbar: the track fills the scroll column, under the // grip's button; the thumb is a pixel short of its right edge, so // the track edges it. const column = scrollColumn(g, chrome, rail.rect.x, layout); try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); if (chrome.decor_checker) |gold| try addChecker(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, gold, dp(@floatFromInt(@max(1, chrome.decor_checker_px))), track, win_w, win_h) else try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); if (rail.thumb_h != 0) { const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; try addDecor(g, gpa, column[0], thumb_y0, column[1] - dp(1), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); } } } /// Halfway between two colours. fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2); return out; } /// A rule's width: the theme's own, or one pixel. fn rulePx(chrome: *const pardes.Chrome) f32 { return dp(@floatFromInt(@max(1, chrome.rule_px))); } /// The display's density (SDL's window display scale: 2 on a 2x screen), /// by which every size given in logical pixels is drawn, as acme scales its /// Border, ButtonBorder and Scrollgap (scalesize). Cells, glyphs and what is /// measured in them are already the display's pixels and are not scaled. /// ponytail: one window per process, so one global; per window if ever two. var display_scale: f32 = 1; /// The window's display scale, or PARDES_TEST_SCALE (a harness forcing 2x); /// a test window is 1x unless forced, whatever display it lands on, so its /// captures are the same on any machine. fn updateDisplayScale(window: *c.SDL_Window) void { if (std.c.getenv("PARDES_TEST_SCALE")) |forced| { display_scale = std.fmt.parseFloat(f32, std.mem.span(forced)) catch 1; } else if (std.c.getenv("PARDES_TEST") != null) { display_scale = 1; } else { const scale = c.SDL_GetWindowDisplayScale(window); display_scale = if (scale > 0 and std.math.isFinite(scale)) scale else 1; } display_scale = std.math.clamp(display_scale, 1, 4); } /// A theme's ornament as the cell shader draws it (Chrome.decor_*), for the /// frame being built: renderFrame sets it, writing a cell marks the cells /// it falls on, and pushLayerOpacity hands the shader its colours and /// sizes. ponytail: one window per process, as display_scale. var cell_decor: CellDecor = .{}; const CellDecor = struct { /// The page's ground, when the theme dots it. dots: ?[3]u8 = null, /// A file name's inks in a tag, when the theme shadows it. title: ?[2][3]u8 = null, /// UiUniforms after background_opacity (ui.frag.glsl): the dots' colour /// and strength, their period and radii, the shadow's colour and offset. uniforms: [12]f32 = @splat(0), /// The tag plaques' bands (window pixels) and the tag grounds: a blank /// tag cell there lets its plaque show through (tagPlaques). plaques: [pardes.MAX_TAG_LAYERS][4]f32 = undefined, nplaques: usize = 0, grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } }, plaques_off: bool = false, fn inPlaque(decor: *const CellDecor, x: f32, y: f32) bool { for (decor.plaques[0..decor.nplaques]) |band| if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return true; return false; } fn of(chrome: *const pardes.Chrome) CellDecor { var out: CellDecor = .{}; if (chrome.decor_dots) |rgb| if (chrome.page) |page| { out.dots = page; out.uniforms[0..4].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(chrome.decor_dot_alpha) }; out.uniforms[4..8].* = .{ dp(@floatFromInt(@max(2, chrome.decor_dot_px))), 0.75 * display_scale, 1.25 * display_scale, 0 }; }; if (chrome.decor_title) |rgb| { out.title = .{ chrome.name_ink, chrome.active_name_ink }; out.uniforms[8..12].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), dp(@floatFromInt(@max(1, chrome.decor_title_px))) }; } return out; } fn unit(v: u8) f32 { return @as(f32, @floatFromInt(v)) / 255; } }; /// A logical pixel size in the display's pixels: rounded, at least one. fn dp(logical: f32) f32 { return @max(1, @round(logical * display_scale)); } /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of /// group `index`: each pane tag, and with the still panes the column and /// workspace tags, becomes a framed box inside its own band with a hard /// shadow down and right that stays inside the band too, so it never falls /// on a body, a grip or the next pane. The focused pane's box is striped, /// its words on plates of the tag's ground (their cells stay opaque; the /// spaces between let the stripes through, emitInstance); a column or the /// workspace takes a quieter box, a one-pixel frame and a two-pixel shadow. fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { const border_rgb = chrome.decor_box_border orelse return; if (cell_decor.plaques_off) return; const page = chrome.page orelse bg_default; for (regions) |tag| { const pane = tag.kind == .tag; if (!pane and tag.kind != .column_tag and tag.kind != .workspace_tag) continue; if (tag.rect.w == 0 or tag.rect.h == 0) continue; if (pane and groupOf(groups, tag.serial) != index) continue; if (!pane and index != 0) continue; const plaque = plaqueBox(g, chrome, regions, tag, layout, win_w, win_h) orelse continue; const b = plaque.border; const sh = plaque.shadow; const fill = if (tag.active) chrome.tag_focus_bg else chrome.tag_bg; // The band's own ground, then the shadow, the frame and the fill. try addDecor(g, gpa, plaque.band[0], plaque.band[1], plaque.band[2], plaque.band[3], page, 1, track, win_w, win_h); const shadow_rgb = chrome.decor_box_shadow orelse border_rgb; try addDecor(g, gpa, plaque.box[0] + sh[0], plaque.box[1] + sh[1], plaque.box[2] + sh[0], plaque.box[3] + sh[1], if (pane) shadow_rgb else mixRgb(shadow_rgb, page), 1, track, win_w, win_h); try addDecor(g, gpa, plaque.box[0], plaque.box[1], plaque.box[2], plaque.box[3], border_rgb, 1, track, win_w, win_h); const inner: [4]f32 = .{ plaque.box[0] + b, plaque.box[1] + b, plaque.box[2] - b, plaque.box[3] - b }; if (pane and tag.active and chrome.decor_box_stripe != null) try addPattern(g, gpa, inner[0], inner[1], inner[2], inner[3], chrome.decor_box_stripe.?, fill, dp(@floatFromInt(@max(1, chrome.decor_box_stripe_px))), true, track, win_w, win_h) else try addDecor(g, gpa, inner[0], inner[1], inner[2], inner[3], fill, 1, track, win_w, win_h); } } const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 }; /// A tag's plaque in window pixels: its band (the rows, from after the /// grip to the end), the framed box round the tagline's text, and the /// frame's and the shadow's widths, the shadow cut to what the band's slack /// below the text and its right end leave. Null where there is no room. fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const pardes.Region, tag: pardes.Region, layout: CellLayout, win_w: f32, win_h: f32) ?Plaque { const pane = tag.kind == .tag; const first = tag.rect.y; const last = tag.rect.y + tag.rect.h - 1; // From its start, right of its grip: the gap cell before its text is // the plaque's margin, so its frame is never under the first letter. _ = regions; const x0 = @as(f32, @floatFromInt(tag.rect.x)) * layout.w; const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; const x1 = if (win_w - x1_cells < layout.w) @max(x1_cells, win_w) else x1_cells; const row_top = @as(f32, @floatFromInt(first)) * layout.h; const row_bottom = @as(f32, @floatFromInt(last + 1)) * layout.h; const text_top = row_top + @as(f32, @floatFromInt(taglineBandOffset(first, win_h, g.cell_h, g.tagline_height))); const text_bottom = @as(f32, @floatFromInt(last)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(last, win_h, g.cell_h, g.tagline_height) + g.tagline_height)); const border = @min(if (pane) dp(@floatFromInt(@max(1, chrome.decor_box_border_px))) else dp(1), @floor(text_top - row_top)); if (border < 1) return null; // The shadow's full offset to the right; down, what the slack under the // text leaves (a pixel or three at a tagline under the body's size). const want: f32 = if (pane) dp(@floatFromInt(chrome.decor_box_shadow_px)) else dp(2); const drop = std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want); if (x1 - x0 < 4 * border + want) return null; return .{ .band = .{ x0, row_top, x1, row_bottom }, .box = .{ x0, text_top - border, x1 - want, text_bottom + border }, .border = border, .shadow = .{ want, drop }, }; } /// The two short bars on each row of a grip with no mode glyph there, /// centered in its anchor. Only the panes of group `index`. fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (regions) |grip| { if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue; if (grip.rect.h == 0 or grip.rect.y >= rows) continue; const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h); // Round the button, its first row is the tag's band: nothing of the // cells under it shows there. const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg; const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w; const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w; try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h); try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); if (grip.kind != .grip) continue; // acme's button ring, two pixels (ButtonBorder) inside the button: // the focused one's in its own fill. An unfocused dirty one keeps a // pixel of band inside its ring, so the four states differ in shape // whatever a theme's colours (a focused dirty one is acme's // modbutton, filled up to its ring). { // The rules drawn over the row (paneDecor, after this) cut into // it: the ring runs inside them, from rule to rule. const tag = for (regions) |part| { if (part.kind == .tag and part.owner == grip.owner) break part; } else grip; const tag_bottom = for (regions) |part| { if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y; } else false; const stacked = !tag_bottom and for (regions) |other| { if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; } else false; const top_rule: f32 = if (tag_bottom) dp(1) else if (stacked) @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height))))) else 0; const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) dp(1) else 0; const x0 = button[0]; const x1 = button[2]; const y0 = button[1] + top_rule; const y1 = button[3] - bottom_rule; const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border; // acme's ButtonBorder: two logical pixels. const b = dp(2); try addDecor(g, gpa, x0, y0, x1, y0 + b, ring, 1, track, win_w, win_h); try addDecor(g, gpa, x0, y1 - b, x1, y1, ring, 1, track, win_w, win_h); try addDecor(g, gpa, x0, y0 + b, x0 + b, y1 - b, ring, 1, track, win_w, win_h); try addDecor(g, gpa, x1 - b, y0 + b, x1, y1 - b, ring, 1, track, win_w, win_h); if (grip.dirty and !grip.active) { const gap = b + dp(1); try addDecor(g, gpa, x0 + b, y0 + b, x1 - b, y0 + gap, band, 1, track, win_w, win_h); try addDecor(g, gpa, x0 + b, y1 - gap, x1 - b, y1 - b, band, 1, track, win_w, win_h); try addDecor(g, gpa, x0 + b, y0 + gap, x0 + gap, y1 - gap, band, 1, track, win_w, win_h); try addDecor(g, gpa, x1 - gap, y0 + gap, x1 - b, y1 - gap, band, 1, track, win_w, win_h); } } const x = grip.rect.x; for (0..grip.rect.h) |line| { const y = grip.rect.y + @as(u16, @intCast(line)); if (x >= cols or y >= rows) continue; const cell = &cells[@as(usize, y) * cols + x]; if (cell.default or cell.printableAscii() != ' ') continue; // The marks are in the button's ink, from the region's state. const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty)); // The marks are the first row's alone, centered in the button. if (line != 0) continue; const center_x = @floor((button[0] + button[2]) / 2); const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; const half_w = @max(dp(1), @min(dp(3), @floor((button[2] - button[0] - 2 * dp(3)) / 2))); // The mode's two bars: a logical pixel thick, a pixel either // side of the middle. const bar = dp(1); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - bar - dp(1)), @floor(center_x + half_w), @floor(center_y - dp(1)), rgb, 1, track, win_w, win_h); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + dp(1)), @floor(center_x + half_w), @floor(center_y + dp(1) + bar), rgb, 1, track, win_w, win_h); } } } test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); for (0..40) |_| core.update(.tick); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px); // acme's 12px, at GripWidth's 150% default. try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px); var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 16; g.cell_h = 20; g.tagline_width = 10; g.tagline_height = 12; var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true); const px = struct { fn w(item: CellInstance) f32 { return (item.x1 - item.x0) / 2 * 1280; } }; var spines: usize = 0; var tracks: usize = 0; for (g.decor.items) |item| { const black = item.br == 0 and item.bg == 0 and item.bb == 0; // No rule at the window's left edge; one 2px spine where columns meet. if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) { try std.testing.expect(item.x0 > -1); spines += 1; } const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1; } try std.testing.expectEqual(@as(usize, 1), spines); try std.testing.expect(tracks >= 2); } test "the rule between stacked panes stays in the tag band's slack, at any tagline size" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); core.newScratchBelow(0); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); const lower = for (s.regionList()) |region| { if (region.kind == .tag and region.rect.y > 2) break region; } else return error.TestExpectedStackedTag; for ([_]u32{ 12, 20 }) |tagline_h| { var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = tagline_h; var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); const top = @as(f32, @floatFromInt(lower.rect.y)) * 20; const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h))); var found = false; for (g.decor.items) |item| { const y0 = (1 - item.y0) / 2 * 480; const y1 = (1 - item.y1) / 2 * 480; if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue; found = true; try std.testing.expect(y1 - y0 <= slack + 0.01); } try std.testing.expect(found); } } test "an empty column is ruled on both sides, as between two filled ones" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); // Newcol from the left pane: an empty column between the two filled ones. core.newColumn(0); pardes.test_api.sync(core); try std.testing.expectEqual(@as(usize, 3), core.ncol); try std.testing.expectEqual(@as(usize, 0), core.col_n[1]); const s = try core.render(core.scratch.allocator()); var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; const layout = fixedCellLayout(&g); var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); const border = [3]f32{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 }; // Its left edge and its right edge (the next column's left): each a // rule `rule_px` wide in the border colour, down to the window's foot. for ([_]usize{ 1, 2 }) |column| { const px: f32 = @floatFromInt(@as(u32, core.col_x[column]) * 10); const rule = for (g.decor.items) |item| { if (@abs(item.x0 - (px / 800 * 2 - 1)) < 1e-4 and @abs(item.x1 - ((px + @as(f32, @floatFromInt(s.chrome.rule_px))) / 800 * 2 - 1)) < 1e-4 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &border)) break item; } else return error.TestExpectedColumnRule; try std.testing.expectEqual(@as(f32, -1), rule.y1); } } test "at a 2x display every logical pixel is two: rules, the tag rule, the grip's ring, the rail's edge" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); for (0..40) |_| core.update(.tick); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); for ([_]f32{ 1, 2 }) |scale| { display_scale = scale; defer display_scale = 1; var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 16; g.cell_h = 32; g.tagline_width = 12; g.tagline_height = 24; var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true); const w = struct { fn of(item: CellInstance) f32 { return (item.x1 - item.x0) / 2 * 1280; } fn h(item: CellInstance) f32 { return (item.y0 - item.y1) / 2 * 768; } }; var spine: ?f32 = null; var tag_rule: ?f32 = null; var ring: ?f32 = null; for (g.decor.items) |item| { const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; // The spine between the columns: black, tall. if (std.mem.eql(u8, &rgb, &.{ 0, 0, 0 }) and w.h(item) > 300) spine = @round(w.of(item)); // The tag rule: #8888cc, wide and thin. if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.of(item) > 200) tag_rule = @round(w.h(item)); // The ring's left side: #8888cc, a tag row tall. if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.h(item) > 10 and w.of(item) < 8) ring = @round(w.of(item)); } try std.testing.expectEqual(@as(?f32, 2 * scale), spine); try std.testing.expectEqual(@as(?f32, 1 * scale), tag_rule); try std.testing.expectEqual(@as(?f32, 2 * scale), ring); // The rail starts after the rule, a logical 2 in, and keeps a // logical 3 clear of the text when the cell is narrow. const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); try std.testing.expectEqual(40 * 16 + 2 * scale, column[0]); g.cell_w = 12; defer g.cell_w = 16; const narrow = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); try std.testing.expectEqual(@as(f32, 40 * 12 + 2 * scale), narrow[0]); try std.testing.expectEqual(@as(f32, 40 * 12) + 12 * @as(f32, @floatFromInt(config.GUTTER)) - 3 * scale, narrow[1]); g.cell_w = 8; // A bar cursor is an eighth of its cell, never under a logical pixel // (an 8 px cell: one pixel, two at 2x). const saved = s.cursor; defer s.cursor = saved; s.cursor = .{ .x = 60, .y = 10, .bar = true }; var boxes: [max_cursors]CursorBox = undefined; const n = cursorBoxes(&g, s, fixedCellLayout(&g), 768, &boxes); const bar = for (boxes[0..n]) |box| { if (box.bar and box.x0 == 60 * 8) break box; } else return error.NoBarCursor; try std.testing.expectEqual(scale, bar.x1 - bar.x0); } } test "decor is read from the regions, each pane's in its own group" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.newShell(1, ""); try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); var g: Gui = undefined; g.decor = .empty; defer g.decor.deinit(gpa); g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; const layout = fixedCellLayout(&g); const ndc = struct { fn x(px: f32) f32 { return px / 800 * 2 - 1; } }; var groups = makeGroups(&.{}, false); try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); // A spine one pixel wide at each column's left edge, down from under the // workspace tag; a rail two pixels wide at each pane's. for (core.col_x[0..core.ncol]) |x| { const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); // Two pixels (rule_px), and none at the window's own edge. const spine = for (g.decor.items) |item| { if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item; } else null; try std.testing.expectEqual(x == 0, spine == null); if (spine) |rule| { try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001); try std.testing.expectEqual(@as(f32, -1), rule.y1); } // The scrollbar fills the scroll column right of the rule. const column = scrollColumn(&g, &s.chrome, x, layout); const rail = for (g.decor.items) |item| { if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item; } else return error.TestExpectedRail; _ = rail; } try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); const still = groups.items[0].decor_count; // Pane 1 moving: its rule and rail leave tier 0 for its track's group, // which moves them with it. const serial = core.panes[1].?.serial; const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; groups = makeGroups(&.{moving}, true); try buildDecor(&g, gpa, s, &groups, layout, 800, 480, groups.tracks == 0); const tracked = groups.items[1]; try std.testing.expect(tracked.decor_count >= 4); try std.testing.expectEqual(still, groups.items[0].decor_count + tracked.decor_count); for (g.decor.items[tracked.decor_start..][0..tracked.decor_count]) |item| try std.testing.expectEqual(@as(u32, @intFromEnum(pardes.animation.Transition.slide)), item.effect); // Cursors stand still while panes move. try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); } /// What a bundled post pass leaves alone (Post.Frame.spare), in window /// pixels: every tag with its grip, the column and workspace tags, and the /// notices, the chrome the focus indicators live in. A grip, the gap after /// it and the tag beside it are one rectangle; past the list's end the last /// grows to cover the rest. fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize { var n: usize = 0; for (surface.regionList()) |region| { switch (region.kind) { .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {}, else => continue, } if (region.rect.w == 0 or region.rect.h == 0) continue; const rect: [4]f32 = .{ @as(f32, @floatFromInt(region.rect.x)) * layout.w, @as(f32, @floatFromInt(region.rect.y)) * layout.h, @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, }; const joined = for (out[0..n]) |*other| { // The same rows, a gap cell or two apart (a grip, the gap after // it, its tag): one band. const gap = 2 * layout.w + 0.5; if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) { other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] }; break true; } } else false; if (joined) continue; if (n == out.len) { const last = &out[n - 1]; last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) }; continue; } out[n] = rect; n += 1; } return n; } const CursorBox = Post.Frame.Cursor; const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS; /// Every cursor on the frame where it is drawn, in window pixels: the grid's /// where no layer has its cell, and each layer's at that layer's pitch. A bar /// is an eighth of its cell wide, a block the whole cell. fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, win_h: f32, out: *[max_cursors]CursorBox) usize { const chrome = &surface.chrome; const page = chrome.page orelse bg_default; var n: usize = 0; if (surface.cursor) |cursor| { if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; const role = cellFontRole(cell); const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h); const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w; const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height)))); out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(dp(1), width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; n += 1; } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); if (top >= bottom) continue; const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; const x = left + @as(f32, @floatFromInt(cursor.x)) * width; const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(dp(1), width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; n += 1; } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (cursor.x >= layer.cols or cursor.y >= layer.rows) continue; const width: f32 = @floatFromInt(g.tagline_width); const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; // Past a full row's end the caret sits at the tag's right edge, as // the grid's does (and the core's cursor box, draw.focusedCursorBox). const x = @min(left + @as(f32, @floatFromInt(cursor.x)) * width, right - if (cursor.bar) @max(dp(1), width / 8) else width); const y = layer.viewport.y + cursor.y; const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(dp(1), width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar }; n += 1; } return n; } /// The bar cursors as decor; a block cursor is its cell's own colours. fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { var boxes: [max_cursors]CursorBox = undefined; for (boxes[0..cursorBoxes(g, surface, layout, win_h, &boxes)]) |box| if (box.bar) try addDecor(g, gpa, box.x0, box.y0, box.x1, box.y1, box.rgb, 1, null, win_w, win_h); } /// The triangle overlay: the workspace pet and the touch debug view, over /// everything. fn buildOverlay( g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, sw: u32, sh: u32, debug_on: bool, ) u32 { if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) }; appendWorkspacePet(g, core, surface, &builder); appendCursorGlide(g, surface, &builder); appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } /// G3: the focused cursor while it glides or blinks, its quad from the core /// (Chrome.cursor_quad, grid cells) in its cursor colour; a little less than /// solid, so the glyphs it passes over stay legible. fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void { const chrome = &surface.chrome; if (g.cursor_quad_alpha <= 0.001) return; if (surface.panelTracks().len != 0) return; const layout = fixedCellLayout(g); var px: [4][2]f32 = undefined; var centre: [2]f32 = .{ 0, 0 }; for (&px, 0..) |*point, i| { point.* = .{ chrome.cursor_quad[i * 2] * layout.w, chrome.cursor_quad[i * 2 + 1] * layout.h }; centre[0] += point[0] / 4; centre[1] += point[1] / 4; } // Its colour: the cursor the cells would draw nearest the quad. var boxes: [max_cursors]CursorBox = undefined; var rgb: [3]u8 = chrome.fg orelse fg_default; var nearest: f32 = std.math.inf(f32); for (boxes[0..cursorBoxes(g, surface, layout, builder.win_h, &boxes)]) |box| { const dx = (box.x0 + box.x1) / 2 - centre[0]; const dy = (box.y0 + box.y1) / 2 - centre[1]; if (dx * dx + dy * dy < nearest) { nearest = dx * dx + dy * dy; rgb = box.rgb; } } const color: OverlayColor = .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255, .g = @as(f32, @floatFromInt(rgb[1])) / 255, .b = @as(f32, @floatFromInt(rgb[2])) / 255, .a = g.cursor_quad_alpha }; builder.addTri(px[0][0], px[0][1], px[1][0], px[1][1], px[2][0], px[2][1], color); builder.addTri(px[0][0], px[0][1], px[2][0], px[2][1], px[3][0], px[3][1], color); } fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, builder: *OverlayBuilder) void { const p = core orelse return; const kind: pet.Kind = @enumFromInt(@intFromEnum(p.settings.pet)); if (kind == .off or surface.rows == 0 or surface.cols == 0) { g.pet_state = .{}; return; } // Read only the rendered global row. No pane/column tags, status text or // mouse targets are changed, and editing the workspace parks the pet. var occupied: usize = 0; for (surface.cells[0..surface.cols], 0..) |*cell, i| { if (cellCodepoint(cell) != ' ') occupied = i + 1; } const editing = p.header_focus and p.header_column == null; const band_top: f32 = @floatFromInt(taglineBandOffset(0, builder.win_h, g.cell_h, g.tagline_height)); const region = if (occupied >= surface.cols) null else pet.lane( occupied, @floatFromInt(g.tagline_width), builder.win_w, band_top, @floatFromInt(g.tagline_height), editing, ); // SDL's regular pump already wakes at least every 16 ms. Quantization in // sample keeps this independent of input, render rate and scene effects, // without creating another timer or forcing core animation ticks. const pose = g.pet_state.sample(kind, c.SDL_GetTicks(), region) orelse return; const ink = overlayRgb(p.chromeTheme().tag_fg); const eye = overlayRgb(p.chromeTheme().tag_bg); for (0..pet.height) |y| for (0..pet.width) |x| { const color = pet.pixel(kind, pose, x, y); if (color == 0) continue; const x0 = pose.x + @as(f32, @floatFromInt(x)) * pose.scale; const y0 = pose.y + @as(f32, @floatFromInt(y)) * pose.scale; builder.addRect(x0, y0, x0 + pose.scale, y0 + pose.scale, if (color == 2) eye else ink); }; } test "pet overlay stays beyond workspace text and vanishes while its tag is edited" { std.testing.refAllDecls(pet); const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer core.deinit(); core.settings.pet = .cat; var cells: [80]pardes.Cell = @splat(.{}); const surface: pardes.Surface = .{ .cols = 80, .rows = 1, .cells = &cells }; for ("Workspace Commands", 0..) |ch, i| { cells[i].text[0] = ch; cells[i].len = 1; cells[i].default = false; } var g: Gui = undefined; g.pet_state = .{}; g.cell_h = 20; g.tagline_height = 16; g.tagline_width = 8; var vertices: [pet.max_vertices]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; appendWorkspacePet(&g, core, &surface, &builder); try std.testing.expect(builder.len > 0); for (vertices[0..builder.len]) |v| { const x = (v.x + 1) * 400; const y = (1 - v.y) * 240; try std.testing.expect(x >= 20 * 8 - 0.01); try std.testing.expect(y >= 0 and y < 20); } pardes.tagline.enterHeader(core, null); builder.len = 0; appendWorkspacePet(&g, core, &surface, &builder); try std.testing.expectEqual(@as(usize, 0), builder.len); } fn cursorColor(chrome: *const pardes.Chrome, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { return if (role == .tagline) switch (cell.style.fg) { .rgb => |rgb| rgb, else => chrome.tag_fg, } else chrome.fg orelse fg_default; } fn overlayRgb(rgb: [3]u8) OverlayColor { return .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, .a = 1, }; } fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; return; } const t = &g.touch; if (t.click_flash_frames != 0) { const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); const label = touchClickLabel(t.click_count, t.click_button); const glyph_scale = 3.0; const char_step = 4.0 * glyph_scale; const pad = 5.0; const stripe_w = 4.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; builder.addRect(0.0, 0.0, builder.win_w, builder.win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); t.click_flash_frames -= 1; } for (&t.points) |*tp| { if (!tp.active) continue; const cx = std.math.clamp(tp.x, 0.0, 1.0) * builder.win_w; const cy = std.math.clamp(tp.y, 0.0, 1.0) * builder.win_h; const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; const color = touchColor(tp.id); if (tp.trail_len > 1) { const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; for (0..tp.trail_len - 1) |trail_i| { const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); var trail_color = color; trail_color.a *= 0.42 * fade; const tx = std.math.clamp(sample.x, 0.0, 1.0) * builder.win_w; const ty = std.math.clamp(sample.y, 0.0, 1.0) * builder.win_h; const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; builder.addCircle(tx, ty, trail_radius, trail_color); } } builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); builder.addCircle(cx, cy, radius, color); builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); builder.addIdTag(tp.id, cx, cy, color); } } fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bool { const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return false; const dst: [*]u8 = @ptrCast(ptr); const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); @memcpy(dst[0..byte_len], src[0..byte_len]); c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); c.SDL_EndGPUCopyPass(copy); return true; } fn pushLayerOpacity(cmd: *c.SDL_GPUCommandBuffer, pass: ?*c.SDL_GPURenderPass, opacity: f32) void { var uniforms = [4]f32{ opacity, 0, 0, 0 } ++ @as([12]f32, @splat(0)); uniforms[4..].* = cell_decor.uniforms; c.SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, @sizeOf(@TypeOf(uniforms))); // The source alpha is geometric/image coverage, not window opacity. Its // alpha contribution is multiplied by this constant separately, so a // rail repainted over a gutter still has alpha p, not p + p*(1-p). c.SDL_SetGPUBlendConstants(pass, .{ .r = opacity, .g = opacity, .b = opacity, .a = opacity }); } fn drawOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { if (vertex_count == 0) return; const pass = rp orelse return; c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); var first: u32 = 0; while (first < vertex_count) { const ink = g.overlay_vertices[first].ink; var end = first + 1; while (end < vertex_count and g.overlay_vertices[end].ink == ink) : (end += 1) {} pushLayerOpacity(cmd, pass, if (ink != 0) 1 else backgroundOpacity(g.applied_window_opacity)); c.SDL_DrawGPUPrimitives(pass, end - first, 1, first, 0); first = end; } } fn touchColor(id: u64) OverlayColor { const palette = [_]OverlayColor{ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, }; return palette[@intCast(id % palette.len)]; } const OverlayBuilder = struct { vertices: []OverlayVertex, len: usize = 0, win_w: f32, win_h: f32, ink: bool = false, fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { if (b.len >= b.vertices.len) return; b.vertices[b.len] = .{ .x = (px / b.win_w) * 2.0 - 1.0, .y = 1.0 - (py / b.win_h) * 2.0, .r = color.r, .g = color.g, .b = color.b, .a = color.a, .ink = @intFromBool(b.ink), }; b.len += 1; } fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { if (b.len + 3 > b.vertices.len) return; b.addVertex(x0, y0, color); b.addVertex(x1, y1, color); b.addVertex(x2, y2, color); } fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { if (b.len + 6 > b.vertices.len) return; const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); if (x1 <= x0 or y1 <= y0) return; b.addTri(x0, y0, x1, y0, x1, y1, color); b.addTri(x0, y0, x1, y1, x0, y1, color); } fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { var i: usize = 0; while (i + 1 < circle_points.len) : (i += 1) { const p0 = circle_points[i]; const p1 = circle_points[i + 1]; b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); } } fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { const label = touchIdLabel(id); const glyph_scale = 2.0; const char_step = 4.0 * glyph_scale; const pad = 4.0; const stripe_w = 3.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; var x = cx + 20.0; if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); var y = cy - label_h - 10.0; if (y < 2.0) y = cy + 20.0; y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); } fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { const previous_ink = b.ink; b.ink = true; defer b.ink = previous_ink; var cx = x; for (text) |ch| { const rows = miniGlyph(ch); const masks = [_]u8{ 0b100, 0b010, 0b001 }; for (rows, 0..) |bits, row| { for (masks, 0..) |mask, colidx| { if ((bits & mask) == 0) continue; const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; const y0 = y + @as(f32, @floatFromInt(row)) * scale; b.addRect(x0, y0, x0 + scale, y0 + scale, color); } } cx += 4.0 * scale; } } }; test "overlay labels preserve ink without exempting surrounding chrome" { var vertices: [192]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 100, .win_h = 100 }; const color: OverlayColor = .{ .r = 1, .g = 1, .b = 1, .a = 1 }; builder.addRect(0, 0, 10, 10, color); const text_start = builder.len; builder.addMiniText("1", 0, 0, 1, color); const text_end = builder.len; try std.testing.expect(text_end > text_start); builder.addRect(20, 20, 30, 30, color); for (vertices[0..builder.len], 0..) |vertex, i| try std.testing.expectEqual(@as(u32, @intFromBool(i >= text_start and i < text_end)), vertex.ink); try std.testing.expect(!builder.ink); } const circle_points = [_][2]f32{ .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, .{ 1.0000000, 0.0000000 }, }; fn touchIdLabel(id: u64) [9]u8 { var out: [9]u8 = undefined; out[0] = '#'; const low: u32 = @truncate(id); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 1] = hexDigit((low >> shift) & 0xf); } return out; } fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { var out: [14]u8 = undefined; @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 6] = hexDigit((count >> shift) & 0xf); } return out; } fn hexDigit(n: u32) u8 { return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); } fn miniGlyph(ch: u8) [5]u8 { return switch (ch) { '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, else => .{ 0, 0, 0, 0, 0 }, }; } fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; var it = view.iterator(); if (it.nextCodepoint()) |cp| { if (it.nextCodepoint() == null) return cp; } } return key.shifted_codepoint orelse key.codepoint; } fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, vaxis.Key.down => pardes.Key.down, vaxis.Key.left => pardes.Key.left, vaxis.Key.right => pardes.Key.right, vaxis.Key.home => pardes.Key.home, vaxis.Key.end => pardes.Key.end, vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, else => cp, }; } fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { const raw = env.get(name) orelse return null; return std.fmt.parseInt(u16, raw, 10) catch null; } test "compact body layer rendering fills recovered rows and clips the final glyph" { var g: Gui = undefined; g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [24]pardes.Cell = @splat(.{}); var layer: pardes.Layer = .{ .viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 }, .cols = 2, .rows = 12, .context_rows = 4, .cells = &cells, }; layer.setContextLines(&.{ 0, 4, 5, 6 }); var instances: [32]CellInstance = undefined; var next: u32 = 0; emitBodyLayer(&g, &instances, &next, &layer, 200, 200, null, Ground.opaqueRgb(bg_default), false, false, .{ 80, 90, 100 }); // Four compact header rows (48px), then three body rows: two complete // and a third clipped to the remaining12px of the100px viewport. try std.testing.expectEqual(@as(u32, 20), next); try std.testing.expectApproxEqAbs(@as(f32, 0.49), instances[3].y0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0.48), instances[3].y1, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 80.0 / 255.0), instances[3].br, 0.0001); try std.testing.expectApproxEqAbs(instances[3].br, instances[13].br, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0.6), instances[0].y0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0.12), instances[14].y0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.4), instances[19].y1, 0.0001); try std.testing.expectApproxEqAbs(instances[19].v0 + @as(f32, 12) / atlas_h, instances[19].v1, 0.0001); // A drag preview reserves the last physical column without changing cells. layer.viewport.w = 1; for ([_]bool{ false, true }) |old| { next = 0; emitBodyLayer(&g, &instances, &next, &layer, 200, 200, null, Ground.opaqueRgb(bg_default), false, old, .{ 80, 90, 100 }); for (instances[0..next]) |instance| try std.testing.expect(instance.x1 <= -0.6 + 0.0001); } } test "compact tag layers fill available width and clip at their physical edge" { var g: Gui = undefined; g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [6]pardes.Cell = @splat(.{}); const layer: pardes.Layer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .rows = 1, .cells = &cells, .bg = .{ 20, 30, 40 } }; var instances: [8]CellInstance = undefined; var next: u32 = 0; emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); // Five six-pixel cells fit in the three body-column viewport. try std.testing.expectEqual(@as(u32, 6), next); try std.testing.expectApproxEqAbs(@as(f32, -0.6), instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0), instances[5].x1, 0.0001); g.tagline_width = 8; next = 0; emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); try std.testing.expectEqual(@as(u32, 5), next); try std.testing.expectApproxEqAbs(@as(f32, 0), instances[4].x1, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 6) / atlas_w, instances[4].u1 - instances[4].u0, 0.0001); }