summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-11 12:38:56 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit682e237df7e8b22f820d14a4adee58e6e2f84268 (patch)
tree5d888cb46f1a49e89fc55e2a4c232d3af0265b1d /src/gui
parent6878e1c309172d624c0d7a3555f6f0217ae6770e (diff)
downloadpardes-682e237df7e8b22f820d14a4adee58e6e2f84268.tar.gz
pardes-682e237df7e8b22f820d14a4adee58e6e2f84268.zip
trunk: resume before the Reload experiment
Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/gui.zig435
-rw-r--r--src/gui/pet.zig170
2 files changed, 593 insertions, 12 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f7b9fc7e..0b2bdd18 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -12,6 +12,7 @@ const message = pardes.Pardes.Message;
const file_watch = @import("../file_watch.zig");
const deck = @import("deck.zig");
const crt = @import("crt.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");
@@ -82,6 +83,22 @@ const Slot = struct { u: u32, v: u32 };
const GlyphKey = struct {
codepoint: u32,
role: pardes.FontRole,
+ decoration: GlyphDecoration = .{},
+};
+
+const GlyphDecoration = struct {
+ 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 {
@@ -191,7 +208,9 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame);
}
-const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+// Per pane: background, rule, two grip strokes, and two square rails.
+const pane_chrome_vertices: usize = 6 * 6;
+const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_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;
@@ -238,15 +257,25 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
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);
+ decorateGlyph(&bitmap, @intCast(stride), @intCast(cell_w), @intCast(cell_h), ascent, .{ .bold = true });
+ 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),
@@ -353,7 +382,8 @@ test "glyph atlas cache separates body and tagline roles" {
defer glyphs.deinit();
try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 });
try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 });
- try std.testing.expectEqual(@as(usize, 2), glyphs.count());
+ try glyphs.put(.{ .codepoint = 'A', .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(.{ .codepoint = 'A', .role = .body }).?.u);
try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u);
}
@@ -1735,6 +1765,7 @@ const Gui = struct {
space_slot: Slot = .{ .u = 0, .v = 0 },
touch: Touch = .{},
+ pet_state: pet.State = .{},
live_ctrl: bool = false,
live_alt: bool = false,
@@ -3674,6 +3705,8 @@ fn pollFrame(ctx: ?*anyopaque) void {
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);
@@ -3699,7 +3732,7 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
core,
frame,
core.theme().bg,
- config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track,
+ config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border,
core.chromeTheme().tag_bg,
core.settings.scene_effects,
core.settings.debug,
@@ -5065,8 +5098,9 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs
}
const cp = if (blank) ' ' else cellCodepoint(cell);
+ const decoration = GlyphDecoration.fromCell(cell);
return .{
- .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role),
+ .slot = if (cp == ' ' and !decoration.drawsBlank()) g.space_slot else ensureGlyph(g, cp, role, decoration),
.fg = fg,
.bg = bg,
.role = role,
@@ -5088,6 +5122,39 @@ fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row:
return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1;
}
+fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 {
+ const chrome = core.chromeTheme();
+ if (!core.settings.focus_tint) return chrome.tag_bg;
+ if (row >= pardes.TOPBAR_H and row < pardes.TOPBAR_H + core.columnBarHeight()) {
+ for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| {
+ if (id != core.active) continue;
+ const x = core.col_x[column];
+ if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg;
+ };
+ }
+ const r = core.rects[core.active];
+ const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
+ return if (r.h > 0 and row == tag_y and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg;
+}
+
+test "compact column and pane underlays share focus tint without tinting the global tag" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .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);
+ core.active = 1;
+ const active = core.rects[1];
+ const inactive = core.rects[0];
+ const chrome = core.chromeTheme();
+ try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0));
+ try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H));
+ try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H));
+ try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y));
+ core.settings.focus_tint = false;
+ try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H));
+}
+
fn emitSurfaceCell(
g: *Gui,
core: ?*const pardes.Pardes,
@@ -5107,7 +5174,12 @@ fn emitSurfaceCell(
) void {
const role = drawnFontRole(core, cell, row);
if (role == .tagline) {
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, tagline_base, old_layer, false, page);
+ var band_base = tagline_base.*;
+ if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) };
+ // 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, old_layer, false, page);
next.* += 1;
const tag_layout = taglineLayoutForCell(g, core, col, row, track);
emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
@@ -5118,6 +5190,29 @@ fn emitSurfaceCell(
next.* += 1;
}
+test "small tagline fonts leave no background gutters between adjacent tags" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ var g: Gui = undefined;
+ 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| {
+ emitSurfaceCell(&g, core, &instances, &next, 0, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, 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);
+ if (row > 0) try std.testing.expectApproxEqAbs(instances[(row - 1) * 2].y1, base.y0, 0.0001);
+ }
+}
+
fn emitInstance(
g: *Gui,
instances: [*]CellInstance,
@@ -5219,10 +5314,11 @@ test "insert cursor overlays without replacing the character beneath it" {
.y_off = 0,
.w = 10,
.h = 20,
- });
+ }, .{ 32, 48, 64 });
try std.testing.expectEqual(@as(usize, 6), builder.len);
try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001);
}
fn palColor(idx: u8) [3]u8 {
@@ -5284,8 +5380,8 @@ fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void {
resetGlyphAtlas(g);
}
-fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot {
- const key: GlyphKey = .{ .codepoint = cp, .role = role };
+fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot {
+ const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration };
if (g.glyphs.get(key)) |s| return s;
if (g.pen_x + g.cell_w > atlas_w) {
g.pen_x = 0;
@@ -5300,12 +5396,82 @@ fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot {
const raster_w = if (role == .tagline) g.tagline_width else g.cell_w;
const raster_h = if (role == .tagline) g.tagline_height else g.cell_h;
_ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline);
+ decorateGlyph(out, atlas_w, raster_w, raster_h, baseline, decoration);
g.glyphs.put(key, s) catch return g.space_slot;
g.pen_x += g.cell_w;
g.atlas_dirty = true;
return s;
}
+// Decorations 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.
+fn decorateGlyph(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void {
+ if (width == 0 or height == 0) return;
+ if (decoration.bold) {
+ // A fractional one-pixel embolden preserves counters at small UI sizes.
+ // Right-to-left traversal reads the original left neighbour each time.
+ for (0..height) |y| {
+ var x = width - 1;
+ while (x > 0) : (x -= 1) {
+ const previous = out[y * stride + x - 1];
+ const target = &out[y * stride + x];
+ target.* = @max(target.*, @as(u8, @intCast(@as(u16, previous) * 3 / 4)));
+ }
+ }
+ }
+ if (decoration.underline != .off) {
+ const base: usize = @intCast(@max(0, baseline));
+ const row = @min(base + 1, height - 1);
+ for (0..width) |x| {
+ switch (decoration.underline) {
+ .off => unreachable,
+ .single => out[row * stride + x] = 255,
+ .double => {
+ const upper = @min(base + 1, height -| 3);
+ out[upper * stride + x] = 255;
+ out[@min(upper + 2, height - 1) * stride + x] = 255;
+ },
+ .curly => out[(row -| @as(usize, if (x % 4 < 2) 1 else 0)) * stride + x] = 255,
+ .dotted => if (x % 3 == 0) {
+ out[row * stride + x] = 255;
+ },
+ .dashed => if (x % 6 < 4) {
+ out[row * stride + x] = 255;
+ },
+ }
+ }
+ }
+ if (decoration.strikethrough) {
+ const row = @min(height - 1, @as(usize, @intCast(@max(0, baseline))) * 2 / 3);
+ @memset(out[row * 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);
+ decorateGlyph(&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());
+}
+
fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void {
const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return;
const dst: [*]u8 = @ptrCast(ptr);
@@ -5709,6 +5875,7 @@ fn addCursorBar(
bar: bool,
visual_height: f32,
layout: CellLayout,
+ rgb: [3]u8,
) void {
if (!bar) return;
const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w;
@@ -5721,9 +5888,9 @@ fn addCursorBar(
x0 + @max(1.0, layout.w / 8.0),
y0 + height,
.{
- .r = @as(f32, @floatFromInt(fg_default[0])) / 255.0,
- .g = @as(f32, @floatFromInt(fg_default[1])) / 255.0,
- .b = @as(f32, @floatFromInt(fg_default[2])) / 255.0,
+ .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.0,
},
);
@@ -5745,6 +5912,7 @@ fn buildOverlay(
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
+ appendWorkspacePet(g, core, surface, &builder);
if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| {
const y0: f32 = @floatFromInt(g.cell_h - height / 2);
const rgb = topbar_pane_border_rgb;
@@ -5755,6 +5923,10 @@ fn buildOverlay(
.a = 1.0,
});
}
+ if (core) |p| if (p.columnBarHeight() > 0) {
+ const y = @as(f32, @floatFromInt(pardes.TOPBAR_H + p.columnBarHeight())) * layout.h;
+ builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border));
+ };
const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h;
if (grid_bottom < win_h and bottomTaglinePresent(surface)) {
const rgb = tagline_rgb;
@@ -5766,6 +5938,7 @@ fn buildOverlay(
});
}
if (transient_on) {
+ if (core) |p| appendPaneChrome(g, p, surface, layout, &builder);
if (surface.cursor) |cursor| {
if (cursor.x < surface.cols and cursor.y < surface.rows) {
const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
@@ -5778,7 +5951,7 @@ fn buildOverlay(
taglineLayoutForCell(g, core, cursor.x, cursor.y, null)
else
layout;
- addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout);
+ addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role));
}
}
}
@@ -5786,6 +5959,244 @@ fn buildOverlay(
return @intCast(builder.len);
}
+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.topbar_col != null 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);
+ }
+ core.topbar_col = 0;
+ core.header_column = null;
+ builder.len = 0;
+ appendWorkspacePet(&g, core, &surface, &builder);
+ try std.testing.expectEqual(@as(usize, 0), builder.len);
+}
+
+fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 {
+ if (core) |p| return if (role == .tagline) switch (cell.style.fg) {
+ .rgb => |rgb| rgb,
+ else => p.chromeTheme().tag_fg,
+ } else p.theme().fg orelse fg_default;
+ return switch (cell.style.fg) {
+ .rgb => |rgb| rgb,
+ .index => |index| palColor(index),
+ // Detached frames carry explicit page backgrounds but blank cells need
+ // not carry foreground ink. Keep their insertion caret visible too.
+ .default => switch (cell.style.bg) {
+ .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140)
+ .{ 32, 32, 32 }
+ else
+ fg_default,
+ else => fg_default,
+ },
+ };
+}
+
+test "detached insertion caret contrasts with blank light and dark pages" {
+ var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } };
+ try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body));
+ cell.style.bg = .{ .rgb = .{ 24, 28, 32 } };
+ try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body));
+ cell.style.fg = .{ .rgb = .{ 122, 188, 160 } };
+ try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body));
+}
+
+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 cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool {
+ return !cell.default and switch (cell.style.bg) {
+ .rgb => |bg| std.mem.eql(u8, &bg, &rgb),
+ else => false,
+ };
+}
+
+// The canonical surface still owns scrollbar positions and hit targets. Only
+// their pixel ink is inset here; no layout, selection or mouse coordinates move.
+fn appendPaneChrome(
+ g: *const Gui,
+ core: *const pardes.Pardes,
+ surface: *const pardes.Surface,
+ layout: CellLayout,
+ builder: *OverlayBuilder,
+) void {
+ const chrome = core.chromeTheme();
+ const page = overlayRgb(core.theme().bg orelse bg_default);
+ for (core.panes, core.rects) |slot, r| {
+ if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue;
+ if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue;
+ const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y;
+ const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_end = body_y + r.h - pardes.BOX_H;
+ const x0 = @as(f32, @floatFromInt(r.x)) * layout.w;
+ const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w;
+ const tag_cell = &surface.cells[@as(usize, tag_y) * surface.cols + r.x];
+ const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x ..][0..r.w];
+ const tag_visible = for (tag_cells) |*cell| {
+ if (cellFontRole(cell) != .tagline) break false;
+ } else true;
+ if (tag_visible) {
+ const rule_y = @as(f32, @floatFromInt(tag_y + @as(u16, if (core.settings.tag_bottom) 0 else 1))) * layout.h;
+ builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border));
+
+ // A quiet grip gives the normal-mode move box a purpose at rest.
+ // Insert and terminal badges retain their existing visible glyphs.
+ if (tag_cell.printableAscii() == ' ' and g.tagline_height >= 6 and g.tagline_width >= 3) {
+ const tag_layout = taglineLayoutForCell(g, core, r.x, tag_y, null);
+ const center_x = tag_layout.x_off + (@as(f32, @floatFromInt(r.x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * tag_layout.w;
+ const center_y = @as(f32, @floatFromInt(tag_y)) * layout.h +
+ @as(f32, @floatFromInt(taglineBandOffset(tag_y, builder.win_h, g.cell_h, g.tagline_height))) +
+ @as(f32, @floatFromInt(g.tagline_height)) / 2;
+ const half_w = @max(1, @min(3, tag_layout.w - 2));
+ const ink = overlayRgb(switch (tag_cell.style.fg) {
+ .rgb => |rgb| rgb,
+ else => chrome.tag_fg,
+ });
+ builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), ink);
+ builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), ink);
+ }
+ }
+
+ // Do not paint over resize previews or debug text occupying the rail.
+ var thumb_start: ?u16 = null;
+ var thumb_end: u16 = body_y;
+ var row = body_y;
+ while (row < body_end) : (row += 1) {
+ const cell = &surface.cells[@as(usize, row) * surface.cols + r.x];
+ if (cell.printableAscii() != ' ') break;
+ if (cellBackgroundIs(cell, chrome.scroll_thumb)) {
+ if (thumb_start == null) thumb_start = row;
+ thumb_end = row + 1;
+ } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break;
+ }
+ if (row != body_end) continue;
+ const y0 = @as(f32, @floatFromInt(body_y)) * layout.h;
+ const y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
+ builder.addRect(x0, y0, x0 + layout.w, y1, page);
+ appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track));
+ if (thumb_start) |first| {
+ const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h;
+ const thumb_y1 = @as(f32, @floatFromInt(thumb_end)) * layout.h;
+ appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb));
+ }
+ }
+ appendColumnSpines(core, surface.rows, layout, builder);
+}
+
+fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void {
+ const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2);
+ builder.addRect(x, y0, x + width, y1, color);
+}
+
+fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void {
+ const y0 = @as(f32, @floatFromInt(pardes.TOPBAR_H)) * layout.h;
+ const y1 = @as(f32, @floatFromInt(rows)) * layout.h;
+ if (y1 <= y0) return;
+ // A single boundary spans all panes, including their tags. Its left edge
+ // agrees with the square scroll markers and existing move-box hit area.
+ for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| {
+ if (width == 0) continue;
+ const x = @as(f32, @floatFromInt(col)) * layout.w;
+ builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border));
+ }
+}
+
+test "square scroll markers share the column edge and have no rounded end inset" {
+ var vertices: [12]OverlayVertex = undefined;
+ var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 };
+ appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 }));
+ appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 }));
+ try std.testing.expectEqual(@as(usize, 12), builder.len);
+ try std.testing.expectEqual(vertices[0].x, vertices[6].x);
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001);
+}
+
+test "column spines remain continuous across every pane tag and body" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .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);
+ var vertices: [12]OverlayVertex = undefined;
+ var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 };
+ appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder);
+ try std.testing.expectEqual(@as(usize, 12), builder.len);
+ for (0..2) |col| {
+ const first = vertices[col * 6];
+ const bottom = vertices[col * 6 + 2];
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001);
+ try std.testing.expectEqual(@as(f32, -1), bottom.y);
+ try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001);
+ }
+}
+
fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void {
if (!debug_on) {
g.touch.click_flash_frames = 0;
diff --git a/src/gui/pet.zig b/src/gui/pet.zig
new file mode 100644
index 00000000..2b031f0e
--- /dev/null
+++ b/src/gui/pet.zig
@@ -0,0 +1,170 @@
+//! Original code-native pixel companions. Pure, bounded geometry and a 10 Hz
+//! animation clock: no textures, allocation, input handling or editor state.
+const std = @import("std");
+
+pub const Kind = enum { off, cat, frog };
+pub const width = 12;
+pub const height = 9;
+pub const max_vertices = width * height * 6;
+pub const Lane = struct { left: f32, right: f32, top: f32, scale: f32 };
+pub const Pose = struct { x: f32, y: f32, scale: f32, left: bool, walking: bool, frame: u8 };
+
+/// Only the trailing blank part of the global tag is eligible. The caller
+/// supplies its last occupied cell, including any wide-grapheme continuation.
+pub fn lane(occupied: usize, cell_w: f32, screen_w: f32, top: f32, band_h: f32, editing: bool) ?Lane {
+ if (editing or cell_w <= 0 or band_h < height + 2) return null;
+ const scale = @min(3, @floor((band_h - 2) / height));
+ const left = @as(f32, @floatFromInt(occupied + 2)) * cell_w;
+ const right = screen_w - 2 * cell_w - width * scale;
+ if (right - left < 4 * scale) return null;
+ return .{ .left = left, .right = right, .top = top + band_h - height * scale - 1, .scale = scale };
+}
+
+pub const State = struct {
+ kind: Kind = .off,
+ tick: ?u64 = null,
+ x: f32 = 0,
+ left: bool = false,
+ idle: u8 = 12,
+ frame: u8 = 0,
+
+ pub fn sample(s: *State, kind: Kind, now_ms: u64, area: ?Lane) ?Pose {
+ const region = area orelse {
+ s.* = .{};
+ return null;
+ };
+ if (kind == .off) {
+ s.* = .{};
+ return null;
+ }
+ const tick = now_ms / 100;
+ if (s.kind != kind or s.tick == null) {
+ s.* = .{ .kind = kind, .tick = tick, .x = region.left };
+ }
+ s.x = std.math.clamp(s.x, region.left, region.right);
+ // Coming back from suspension never launches the pet across the tag.
+ const steps = @min(tick -| s.tick.?, 2);
+ s.tick = tick;
+ for (0..steps) |_| {
+ s.frame +%= 1;
+ if (s.idle > 0) {
+ s.idle -= 1;
+ continue;
+ }
+ const speed: f32 = if (kind == .cat) 1.8 else 1.3;
+ s.x += (if (s.left) -speed else speed) * region.scale;
+ if (s.x >= region.right or s.x <= region.left) {
+ s.x = std.math.clamp(s.x, region.left, region.right);
+ s.left = !s.left;
+ s.idle = if (kind == .cat) 18 else 24;
+ }
+ }
+ return .{ .x = @floor(s.x), .y = region.top, .scale = region.scale, .left = s.left, .walking = s.idle == 0, .frame = s.frame };
+ }
+};
+
+const cat = [_][]const u8{
+ " # # ",
+ " #####",
+ " ##o##",
+ " # ######",
+ " # ######## ",
+ " ######### ",
+ " ######## ",
+ " ## ## ",
+ " # # ",
+};
+const frog = [_][]const u8{
+ " ",
+ " ## ## ",
+ " #o####o# ",
+ " ######## ",
+ " ###### ",
+ " ######## ",
+ " ########## ",
+ " ## ## ",
+ "### ###",
+};
+
+/// 0 transparent, 1 theme ink, 2 eye cutout. Walk legs and idle blinks are
+/// generated in this fixed 12x9 tile; neither can spill into an adjacent row.
+pub fn pixel(kind: Kind, pose: Pose, x_in: usize, y: usize) u2 {
+ if (x_in >= width or y >= height or kind == .off) return 0;
+ const x = if (pose.left) width - 1 - x_in else x_in;
+ const alternate = (pose.frame / 2) % 2 == 1;
+ // An idle companion still looks alive during its first pause: a slow
+ // two-pixel tail-tip flick, or the frog's throat gently inflating. These
+ // remain inside the tile and mirror with the rest of the animal.
+ const breathing = (pose.frame / 4) % 2 == 1;
+ if (!pose.walking and breathing) {
+ if (kind == .cat and y == 3 and x <= 1)
+ return @intFromBool(x == 0);
+ if (kind == .frog and y == 4 and (x == 2 or x == 9)) return 1;
+ }
+ if (pose.walking and alternate and y >= 7) {
+ if (kind == .cat) return @intFromBool((y == 7 and (x == 3 or x == 7)) or (y == 8 and (x == 2 or x == 8)));
+ return @intFromBool((y == 7 and (x == 2 or x == 9)) or (y == 8 and (x <= 2 or x >= 9)));
+ }
+ const ch = (if (kind == .cat) cat else frog)[y][x];
+ return switch (ch) {
+ '#' => 1,
+ 'o' => if (!pose.walking and pose.frame % 30 >= 27) 1 else 2,
+ else => 0,
+ };
+}
+
+test "pet stays in trailing workspace blanks and disappears during editing" {
+ try std.testing.expect(lane(20, 8, 200, 0, 16, false) == null);
+ try std.testing.expect(lane(3, 8, 200, 0, 16, true) == null);
+ try std.testing.expect(lane(3, 8, 200, 0, 10, false) == null);
+ const region = lane(3, 8, 200, 2, 20, false).?;
+ try std.testing.expect(region.left >= 5 * 8);
+ var state: State = .{};
+ var moved = false;
+ var reversed = false;
+ for (0..2000) |i| {
+ const pose = state.sample(.cat, i * 100, region).?;
+ try std.testing.expect(pose.x >= region.left and pose.x + width * pose.scale <= 200 - 16);
+ try std.testing.expect(pose.y >= 2 and pose.y + height * pose.scale < 22);
+ moved = moved or pose.walking;
+ reversed = reversed or pose.left;
+ }
+ try std.testing.expect(moved and reversed);
+ try std.testing.expect(state.sample(.cat, 300000, null) == null);
+ try std.testing.expect(state.sample(.off, 300100, region) == null);
+}
+
+test "pet clock is fixed rate and sprites bounded in both directions" {
+ const region = lane(1, 8, 400, 0, 16, false).?;
+ var state: State = .{};
+ const start = state.sample(.frog, 1000, region).?;
+ try std.testing.expectEqualDeep(start, state.sample(.frog, 1099, region).?);
+ for ([_]Kind{ .cat, .frog }) |kind| for ([_]bool{ false, true }) |left| {
+ const pose: Pose = .{ .x = 0, .y = 0, .scale = 1, .left = left, .walking = true, .frame = 3 };
+ var count: usize = 0;
+ for (0..height) |y| for (0..width) |x| {
+ count += @intFromBool(pixel(kind, pose, x, y) != 0);
+ };
+ try std.testing.expect(count > 20 and count <= width * height);
+ try std.testing.expectEqual(@as(u2, 0), pixel(kind, pose, width, height));
+ };
+}
+
+test "pet initial idle visibly animates without walking or leaving its tile" {
+ const region = lane(1, 8, 400, 0, 16, false).?;
+ for ([_]Kind{ .cat, .frog }) |kind| {
+ var state: State = .{};
+ const initial = state.sample(kind, 0, region).?;
+ var later = initial;
+ for (1..5) |tick| later = state.sample(kind, tick * 100, region).?;
+ try std.testing.expect(!initial.walking and !later.walking);
+ try std.testing.expectEqual(initial.x, later.x);
+ try std.testing.expectEqual(initial.y, later.y);
+ var changes: usize = 0;
+ for (0..height) |y| for (0..width) |x| {
+ changes += @intFromBool(pixel(kind, initial, x, y) != pixel(kind, later, x, y));
+ };
+ try std.testing.expectEqual(@as(usize, 2), changes);
+ try std.testing.expectEqual(@as(u2, 0), pixel(kind, later, width, height));
+ }
+}