From 682e237df7e8b22f820d14a4adee58e6e2f84268 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 11 Sep 2026 12:38:56 -0300 Subject: 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. --- src/gui/gui.zig | 435 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/gui/pet.zig | 170 ++++++++++++++++++++++ 2 files changed, 593 insertions(+), 12 deletions(-) create mode 100644 src/gui/pet.zig (limited to 'src/gui') 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)); + } +} -- cgit v1.3