From e0b5564f04aa0ae6666f29ef29c768d800fbabce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:15:41 -0300 Subject: Step core animation by the shell's clock and sleep to the next wake A shell now answers Host.now, its monotonic clock in ns, and the pump advances core animation to it: one .tick per whole 16 ms frame since core time last stood still. The same animation therefore takes the same time at 60, 120 and 144 Hz, over ssh and after a slow frame (the GUI's re-armed clock ran them ~25% slow at 144 Hz; the tty's post-frame sleep drifted). nextWake() lists every core animation in one place: a frame from now while anything moves, the end of the wait while something only waits (a message lingering, the look-hover delay), null when idle. Shells sleep exactly that long, and a wait is jumped to its end in one step, so an 800 ms linger costs one frame instead of fifty. An overshoot under 1.5 ms after a step is let go: at 60 and 120 Hz every display frame takes exactly one step (a 16 ms frame against a 16.67 ms vsync would otherwise double-step every 24th). The six tick drivers are gone: tty's timer thread only times the wait, interruptibly (a newer, shorter request cuts short a sleep still timing a longer one); the GUI's AnimationClock, web's JS tick bank (pardes_tick now takes rAF's timestamp), the detached server's and the board's ticks; grid mode steps a virtual clock straight to each wake. PARDES_TEST_CLOCK, set by the snapshot harness, gives tty and the detached server the same virtual clock. Every stepped frame is drawn. The old pump never drew the last frame of a fade (a .tick asks for no frame, and the fade was over by the check), so a theme switch stopped at 9/10 of the way until the next input: theme.golden, themesel.golden and the GUI's acme-light scene move to the theme's true colours, and nothing else changes. The frozen previous grid is captured only while a panel transition is chosen, not on every idle frame. --- src/detached/server.zig | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) (limited to 'src/detached/server.zig') diff --git a/src/detached/server.zig b/src/detached/server.zig index 5c0e1487..212c9a3d 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -140,6 +140,8 @@ pub const Session = struct { worker_gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock). + test_clock: ?u64 = null, listener: c_int = -1, path_buf: [sun_path_len]u8 = undefined, path_len: usize = 0, @@ -343,8 +345,14 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } + fn clockNow(ctx: ?*anyopaque) u64 { + const s = of(ctx); + return s.test_clock orelse host_io.monotonicNs(); + } + const vtable: host_io.Host.VTable = .{ .wait_input = waitInput, + .now = clockNow, .present = present, .poll_frame = pollFrame, .spawn = spawn, @@ -827,8 +835,9 @@ pub const Session = struct { pardes.turn.rest(); nap(if (timeout_ms == 0) 16 else timeout_ms); pardes.turn.wake(); - // An animating frame's wait is the frame: tick it. - if (timeout_ms != 0) s.core.update(.tick); + if (timeout_ms != 0) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*); + }; return; } var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); @@ -838,9 +847,10 @@ pub const Session = struct { const ready = libc.poll(&fds, @intCast(n), timeout); pardes.turn.wake(); if (ready > 0) s.dispatch(fds[0..n], src[0..n]); - // Animating, the frame's time is up: advance it. There is no other - // tick in a detached session, and without it an animation never ends. - if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick); + // The core's wake ran out; the pump advances it from `now`. + if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*); + }; _ = s.drainCompletions(true); s.expire(monotonicMs()); s.harvest(); @@ -1155,6 +1165,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void .cols = options.cols, .rows = options.rows, .prompt_rcs = host_io.Shell.prepare(), + .test_clock = host_io.testClock(), }; defer session.core.deinit(); defer session.deinit(); -- cgit v1.3 From d88e381793c6505bf3f2af43705bd2fa32f55ef6 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:37:29 -0300 Subject: One Layer for tags, notices, headers and bodies; a taller tag is one layer of rows; wire v8 src/Layer.zig merges TagLayer and BodyLayer. `rows` (0 = no layer) and a cursor at {x, y}. A tag of N rows is ONE layer of N grid rows: tagHit answers the row as `line`, bodyHit keeps its meaning, and the per-line layer bases (TAG_LINE_LAYER_BASE, HEADER_LINE_LAYER_BASE) are gone, not aliased. Wire v8, the one bump: tag layers carry rows and cursor y, and the frame carries the placed region list. v7 and v9 peers are refused in both directions (server test over both, a new frontend test over both). web: tag_layer_value 11 = rows, 12 = cursor y; app.mjs lays every row. macOS: the Zig side compiles against Layer; pardes.h still sees one row per tag layer (accepted, the macOS shell is ignored for now). No visual change: snapshot goldens and the 17 GUI goldens byte-identical. Shared files touched: pardes.zig, Messages.zig, gui.zig, macos.zig, detached/client.zig, detached/server.zig, detached/wire.zig (plus web.zig, app.mjs, edit.zig, look.zig). Not touched: mouse.zig, tagline.zig, colors.zig, tty.zig, dump.zig, exec.zig, host_io.zig, panes.zig. --- docs/render-pipeline.md | 2 +- src/Layer.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Messages.zig | 6 +- src/Presentation.zig | 12 +-- src/body_layer.zig | 117 ++-------------------------- src/detached/client.zig | 30 ++++++-- src/detached/server.zig | 3 +- src/detached/wire.zig | 118 ++++++++++++++++++++--------- src/draw.zig | 187 +++++++++++++++++++++++---------------------- src/edit.zig | 2 +- src/gui/gui.zig | 139 +++++++++++++++++---------------- src/look.zig | 2 +- src/macos.zig | 12 +-- src/pardes.zig | 40 +++++----- src/surface.zig | 15 ++-- src/tag_layer.zig | 87 ++------------------- src/web.zig | 12 ++- src/web/app.mjs | 38 ++++++---- test/column_drag.zig | 10 +-- test/panes.zig | 30 ++++---- 20 files changed, 590 insertions(+), 470 deletions(-) create mode 100644 src/Layer.zig (limited to 'src/detached/server.zig') diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index f63b9a0b..a8b3f019 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -809,7 +809,7 @@ stripped. No visual change until stage 9 unless stated. | 4 | PLACE: Region list built once; renderPane/tags/notices read their rects from it | wait for tag→Text; the refactor is already replacing BOX_H with `pane.tag_rows` and `p.tagTop/bodyTop(pane, r)` — PLACE absorbs those into the region rects | | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | -| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now) | +| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | diff --git a/src/Layer.zig b/src/Layer.zig new file mode 100644 index 00000000..e979eab7 --- /dev/null +++ b/src/Layer.zig @@ -0,0 +1,198 @@ +//! A band of cells a pixel shell draws over the grid at its own pitch: a +//! pane's, a column's or the workspace's tag rows, a notice chip, or a file +//! body whose compact tree-sitter context rows outnumber its grid rows. The +//! grid under it holds a copy of what fits there (draw.zig joins them), so a +//! terminal client, which has only the grid, loses nothing but the pitch. +//! +//! Its viewport is in grid cells, so compact text never moves a hit target. +//! A tag's rows are each one grid row, their glyphs at the tagline's size; a +//! body's first `context_rows` rows are stacked at the tagline's height, the +//! rest at the body's. `rows == 0` is no layer. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const limits = @import("memory.zig").limits; + +const Layer = @This(); + +/// `notice` is a pane's transient line -- a builtin's name, a pending leader +/// chord -- carried as a tagline-shaped band so it gets the tagline's height, +/// small-font metrics, band offset and border instead of being a body-grid row +/// wearing a tagline font. +pub const Kind = enum(u8) { workspace, column, pane, notice, body }; + +pub const Metrics = struct { + body_w: u16 = 1, + body_h: u16 = 1, + tagline_w: u16 = 1, + tagline_h: u16 = 1, +}; + +/// A point on a tag, notice or header layer: which row of it and which +/// column at the tagline's pitch. +pub const TagHit = struct { + kind: Kind, + id: u16, + serial: u32, + col: u16, + /// Which row of a taller tag. + line: u16 = 0, + pixel_x: f32, + pixel_y: f32, + metrics: Metrics, +}; + +kind: Kind = .body, +/// The pane slot, or the column, it belongs to. +id: u16 = 0, +serial: u32 = 0, +viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, +cols: u16 = 0, +rows: u16 = 0, +/// A body's compact rows, from its top. +context_rows: u16 = 0, +context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), +/// `rows * cols`, row after row. +cells: []pardes.Cell = &.{}, +cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, +/// A band's fill, where its cells run out. +bg: [3]u8 = .{ 0, 0, 0 }, +/// Vertical offset in rows (negative: above its viewport), for a band +/// that is moving into place. A pixel shell draws it offset and clipped +/// to the rows below the viewport's top; the grid has no such thing. +slide: f32 = 0, +/// How present a notice band is (1 = fully) while it fades up or +/// dissolves. `bg` and the cells are already blended by it; a pixel +/// shell uses it for what it draws around the band, like its rule. +fade: f32 = 1, + +/// Tag columns a viewport this wide holds at the tagline's pitch. +pub fn capacity(layer: *const Layer, metrics: Metrics) u16 { + return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); +} + +pub fn setContextLines(layer: *Layer, lines: []const i32) void { + layer.context_separators = .initEmpty(); + const count = @min(lines.len, layer.context_rows); + var index: usize = 0; + while (index + 1 < count and index < limits.wrap_rows) : (index += 1) { + if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index); + } +} + +pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool { + if (index >= layer.context_rows or index >= limits.wrap_rows) return false; + return index + 1 == layer.context_rows or layer.context_separators.isSet(index); +} + +/// Pixel top of row `index`: a body's compact rows stacked at the tagline's +/// height, every other row a grid row. +pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + const body = @max(1, body_h); + const tag = std.math.clamp(tagline_h, 1, body); + return @as(f32, @floatFromInt(layer.viewport.y)) * body + + @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag + + @as(f32, @floatFromInt(index -| layer.context_rows)) * body; +} + +pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); +} + +const Point = struct { index: u16, col: u16, metrics: Metrics }; + +/// The row and column a window pixel lands on, each row at its own pitch: +/// a tag's at the tagline's width, a body's compact rows too. +fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { + if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; + // A band's hit is refused outright on metrics no host could have; a + // body's has always been floored to a pixel instead. + if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| + if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + const bw = @max(1, body_w); + const bh = @max(1, body_h); + const tw = std.math.clamp(tagline_w, 1, bw); + const th = std.math.clamp(tagline_h, 1, bh); + const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; + if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; + const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; + const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); + if (index >= layer.rows) return null; + const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw; + return .{ + .index = index, + .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))), + .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, + }; +} + +/// A body layer's pixel as the body cell under it (grid columns, from the +/// pane's text edge). +pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { + if (layer.kind != .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +/// A band's pixel as the tag cell under it. +pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit { + if (layer.kind == .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +test "compact body layer rows fill reclaimed height and invert exact boundaries" { + const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; + try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); + try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); + try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); + try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); + for (0..12) |index| { + const r: u16 = @intCast(index); + const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2; + const width: f32 = if (r < 5) 6 else 10; + const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?; + try std.testing.expectEqual(r, hit.row); + try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); + } + try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row); + try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null); +} + +test "compact context borders separate source gaps and preserve multiline headers" { + var layer: Layer = .{ .context_rows = 6 }; + layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); + const expected = [_]bool{ false, true, false, false, true, true, false }; + for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index)); + layer.context_rows = 2; + layer.setContextLines(&.{ 9, 10 }); + try std.testing.expect(!layer.hasContextBorderAfter(0)); + try std.testing.expect(layer.hasContextBorderAfter(1)); + try std.testing.expect(!layer.hasContextBorderAfter(4)); + try std.testing.expect(!layer.context_separators.isSet(4)); +} + +test "tag text capacity uses its own pitch and excludes the physical grip" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 }; + try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); + try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null); + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col); + try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col); + try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null); +} + +test "a taller tag's rows are grid rows, each hit on its own line" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 }; + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line); + try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null); +} diff --git a/src/Messages.zig b/src/Messages.zig index 7334de95..b7153e5c 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -747,7 +747,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" { _ = try p.render(frame.allocator()); const r = p.rects[p.active]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(@as(u16, 1), band.viewport.h); try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y); @@ -794,7 +794,7 @@ test "notices stack over the body's top rows without moving them" { var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; try std.testing.expectEqual(@as(u16, 1), layer.viewport.h); // Sized to its own message, right up against the pane's right edge -- // not the width of the pane. @@ -814,7 +814,7 @@ test "notices stack over the body's top rows without moving them" { _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); for (p.surface.tagLayers()) |*layer| - try std.testing.expect(layer.cols == 0 or layer.kind != .notice); + try std.testing.expect(layer.rows == 0 or layer.kind != .notice); } test "an idle core wants no wake and a lingering message sleeps to its linger's end" { diff --git a/src/Presentation.zig b/src/Presentation.zig index 45b196c8..e1f1b585 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -42,8 +42,8 @@ shown_closing_len: usize = 0, acknowledged: bool = false, pending: bool = false, previous_cells: []pardes.Cell = &.{}, -previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), -previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), +previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), +previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -335,7 +335,7 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { - const count = if (current.cols > 0) current.cells.len else 0; + const count = if (current.rows > 0) current.cells.len else 0; const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { self.previous_valid = false; self.previous_layout = @splat(null); @@ -684,7 +684,7 @@ test "presentation submission failure preserves shown geometry until retry is ac } test "fractional panel inverse preserves compact header rows through slide and zoom" { - const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; + const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { var presentation: Presentation = .{}; const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; @@ -699,7 +699,7 @@ test "fractional panel inverse preserves compact header rows through slide and z const point = presentation.pointerFractional(80, 30, x, y).?; try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); - const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; + const body_hit = layer.bodyHit(point.x * 10, point.y * 20, 10, 20, 8, 10).?; try std.testing.expectEqual(row, body_hit.row); } } @@ -715,7 +715,7 @@ test "presentation snapshots own compact body and tag rows across later edits" { surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; var tag: [1]pardes.Cell = .{.{}}; tag[0].text[0] = 'T'; - surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; + surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .rows = 1, .cells = &tag }; state.capturePrevious(std.testing.allocator, &surface); try std.testing.expect(state.previous_valid); tag[0].text[0] = 'U'; diff --git a/src/body_layer.zig b/src/body_layer.zig index ac87a07b..583a2e98 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -1,9 +1,6 @@ -//! A pane body as the surface shows it. A file body's logical rows can -//! outnumber its physical grid rows: hosts place the compact declaration rows -//! first and clip the remaining body rows. After the layer types come the -//! functions that draw a body into the surface: its text, colours, selections -//! and cursor; then the tags drawn beside it, a pane's and the column and -//! workspace ones, each line a tag layer of its own. +//! Drawing a pane body into a surface: its text, colours, selections and +//! cursor, and joining a body with compact context rows through its layer +//! (Layer.zig). const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); @@ -29,7 +26,7 @@ const tagline = @import("tagline.zig"); const Messages = @import("Messages.zig"); const Text = panes.Text; const MAX_PANES = pardes.MAX_PANES; -const TagKind = @import("tag_layer.zig").Kind; +const TagKind = @import("Layer.zig").Kind; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; @@ -40,108 +37,7 @@ const COLBAR_H = pardes.COLBAR_H; const TAG_GAP = pardes.TAG_GAP; const wordBoundsAtCol = pardes.wordBoundsAtCol; -pub const Metrics = struct { - body_w: u16 = 1, - body_h: u16 = 1, - tagline_w: u16 = 1, - tagline_h: u16 = 1, -}; - -pub const Layer = struct { - pane: u16 = 0, - serial: u32 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - rows: u16 = 0, - context_rows: u16 = 0, - context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), - cells: []pardes.Cell = &.{}, - cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - - pub fn setContextLines(layer: *Layer, lines: []const i32) void { - layer.context_separators = .initEmpty(); - const count = @min(lines.len, layer.context_rows); - var row: usize = 0; - while (row + 1 < count and row < limits.wrap_rows) : (row += 1) { - if (lines[row + 1] != lines[row] +| 1) layer.context_separators.set(row); - } - } - - pub fn hasContextBorderAfter(layer: *const Layer, row: usize) bool { - if (row >= layer.context_rows or row >= limits.wrap_rows) return false; - return row + 1 == layer.context_rows or layer.context_separators.isSet(row); - } - - pub fn rowTop(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - const body = @max(1, body_h); - const tag = std.math.clamp(tagline_h, 1, body); - return @as(f32, @floatFromInt(layer.viewport.y)) * body + - @as(f32, @floatFromInt(@min(row, layer.context_rows))) * tag + - @as(f32, @floatFromInt(row -| layer.context_rows)) * body; - } - - pub fn rowHeight(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - return if (row < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { - if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; - const row: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); - if (row >= layer.rows) return null; - const col: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / (if (row < layer.context_rows) tw else bw)))); - return .{ - .serial = layer.serial, - .row = row, - .col = col + config.GUTTER, - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "compact body layer rows fill reclaimed height and invert exact boundaries" { - const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; - try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); - try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); - try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); - try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); - for (0..12) |index| { - const row: u16 = @intCast(index); - const y = layer.rowTop(row, 20, 12) + layer.rowHeight(row, 20, 12) / 2; - const width: f32 = if (row < 5) 6 else 10; - const hit = layer.hitAt(40 + 3.5 * width, y, 10, 20, 6, 12).?; - try std.testing.expectEqual(row, hit.row); - try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); - } - try std.testing.expectEqual(@as(u16, 4), layer.hitAt(45, 119.99, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 5), layer.hitAt(45, 120, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 11), layer.hitAt(45, 259.99, 10, 20, 6, 12).?.row); - try std.testing.expect(layer.hitAt(45, 260, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(39.99, 80, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(std.math.nan(f32), 80, 10, 20, 6, 12) == null); -} - -test "compact context borders separate source gaps and preserve multiline headers" { - var layer: Layer = .{ .context_rows = 6 }; - layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); - const expected = [_]bool{ false, true, false, false, true, true, false }; - for (expected, 0..) |border, row| try std.testing.expectEqual(border, layer.hasContextBorderAfter(row)); - layer.context_rows = 2; - layer.setContextLines(&.{ 9, 10 }); - try std.testing.expect(!layer.hasContextBorderAfter(0)); - try std.testing.expect(layer.hasContextBorderAfter(1)); - try std.testing.expect(!layer.hasContextBorderAfter(4)); - try std.testing.expect(!layer.context_separators.isSet(4)); -} +const Layer = @import("Layer.zig"); // ---- drawing a pane body into the surface ---- @@ -305,7 +201,8 @@ pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } - layer.pane = @intCast(id); + layer.kind = .body; + layer.id = @intCast(id); layer.serial = pane.serial; const body = s.region(.body, id).?; layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; diff --git a/src/detached/client.zig b/src/detached/client.zig index 4468c961..3347f609 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -103,8 +103,10 @@ pub const Client = struct { grid: std.ArrayListUnmanaged(pardes.Cell) = .empty, cursor: ?wire.Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, - body_layers: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}), - tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), + body_layers: [pardes.MAX_PANES]pardes.Layer = @splat(.{}), + tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), + /// The session's placed regions, as its last frame gave them. + regions: std.ArrayListUnmanaged(pardes.Region) = .empty, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -182,6 +184,7 @@ pub const Client = struct { layer.* = .{}; } for (&c.tag_layers) |*layer| c.gpa.free(layer.cells); + c.regions.deinit(c.gpa); c.in.deinit(c.gpa); c.out.deinit(c.gpa); } @@ -319,7 +322,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; @@ -995,6 +998,12 @@ test "detached session: a frontend that dies takes nothing with it" { } test "detached session: a frontend speaking another protocol is refused, loudly" { + // The protocol before this one (v7 carried one-row tags and no regions) + // and the one after. + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| try refusedHello(claimed); +} + +fn refusedHello(claimed: u16) !void { var h: Harness = undefined; try h.init(60, 16); defer h.deinit(); @@ -1006,7 +1015,7 @@ test "detached session: a frontend speaking another protocol is refused, loudly" defer _ = libc.close(fd); var buf: [64]u8 = undefined; const hello = try wire.encodeClient(&buf, .{ - .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 }, + .hello = .{ .version = claimed, .cols = 60, .rows = 16 }, }); try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal)); @@ -1024,6 +1033,17 @@ test "detached session: a frontend speaking another protocol is refused, loudly" try testing.expect(!h.core.quit); } +test "detached frontend: a session speaking another protocol is not attached to" { + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| { + var c: Client = .{ .gpa = testing.allocator }; + defer c.deinit(); + var buf: [64]u8 = undefined; + try c.in.appendSlice(testing.allocator, try wire.encodeServer(&buf, .{ .welcome = .{ .version = claimed, .slot = 0, .cols = 60, .rows = 16 } })); + try testing.expectError(error.Ungreeted, c.next()); + try testing.expect(!c.attached()); + } +} + test "detached session: a peer that sends garbage is dropped, not obeyed" { var h: Harness = undefined; try h.init(60, 16); @@ -1281,7 +1301,7 @@ fn pumpUntilNotice(h: *Harness, c: *Client, want: []const u8) !bool { try c.wait(5); while (try c.next()) |_| {} for (&c.tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; var n: usize = 0; for (layer.cells[0..layer.cols]) |cell| { const g = cell.grapheme(); diff --git a/src/detached/server.zig b/src/detached/server.zig index 212c9a3d..46fb8fcf 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -739,7 +739,7 @@ pub const Session = struct { fn sendFrame(s: *Session, c: *Client, surface: *const pardes.Surface) void { const cells = surface.cells; - const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers); + const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers, surface.regionList()); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len) &.{} @@ -759,6 +759,7 @@ pub const Session = struct { prev, &surface.body_layers, &surface.tag_layers, + surface.regionList(), ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 94bd7f0b..684c869b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -7,7 +7,9 @@ const limits = @import("../memory.zig").limits; /// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value /// an older frontend would reject mid-session. The greeting is where that has /// to be refused, which is what this number is for. -pub const version: u16 = 7; +/// 8: one layer type: a tag band carries all its rows (not a layer per +/// line), and a frame carries the core's placed regions after its layers. +pub const version: u16 = 8; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; @@ -167,7 +169,8 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer, tags: *[pardes.MAX_TAG_LAYERS]pardes.TagLayer) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + regions.clearRetainingCapacity(); for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -191,13 +194,14 @@ pub const Frame = struct { const count = try r.getU16(); if (count > layers.len) return error.Overlong; for (layers[0..count]) |*layer| { - layer.pane = try r.getU16(); + layer.kind = .body; + layer.id = try r.getU16(); layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); layer.rows = try r.getU16(); layer.context_rows = try r.getU16(); - if (layer.pane >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or + if (layer.id >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.rows or layer.context_rows > limits.wrap_rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or layer.viewport.y > f.rows or layer.viewport.h > f.rows - layer.viewport.y) return error.BadValue; @@ -217,19 +221,40 @@ pub const Frame = struct { layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); + layer.rows = try r.getU16(); for (&layer.bg) |*channel| channel.* = try r.getByte(); - if (layer.cols == 0 or layer.viewport.w == 0 or layer.viewport.h != 1 or + if (layer.cols == 0 or layer.viewport.w == 0 or layer.rows == 0 or layer.rows > pardes.MAX_TAG_ROWS or + layer.viewport.h != layer.rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or - layer.viewport.y >= f.rows or (switch (layer.kind) { + layer.viewport.y >= f.rows or layer.viewport.h > f.rows - layer.viewport.y or (switch (layer.kind) { .workspace => layer.id != 0, .column => layer.id >= pardes.MAX_COLS, .pane, .notice => layer.id >= pardes.MAX_PANES, + .body => true, })) return error.BadValue; - const cursor = try getCursor(&r, layer.cols, 1); - layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null; - layer.cells = try gpa.alloc(pardes.Cell, layer.cols); + const cursor = try getCursor(&r, layer.cols, layer.rows); + layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null; + layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } + const region_count = try r.getU16(); + if (region_count > pardes.MAX_REGIONS) return error.Overlong; + try regions.ensureTotalCapacity(gpa, region_count); + for (0..region_count) |_| { + var region: pardes.Region = .{ .kind = try r.getTag(pardes.Region.Kind), .rect = undefined }; + region.owner = try r.getByte(); + region.serial = try r.getU32(); + region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; + region.active = try r.getBool(); + region.thumb_y = try r.getU16(); + region.thumb_h = try r.getU16(); + region.notice = try r.getByte(); + region.clip = try r.getU16(); + if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or + region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or + region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; + regions.appendAssumeCapacity(region); + } try r.end(); } @@ -672,21 +697,23 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); } -pub fn layersBound(layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) usize { - var n: usize = 4; +pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { + var n: usize = 6; for (layers) |layer| if (layer.rows != 0) { n += 26 + layer.context_rows + layer.cells.len * cell_max; }; - for (tags) |layer| if (layer.cols != 0) { - n += 26 + layer.cells.len * cell_max; + for (tags) |layer| if (layer.rows != 0) { + n += 28 + layer.cells.len * cell_max; }; - return n; + return n + regions.len * region_len; } -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) Error![]const u8 { +const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; + +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -708,7 +735,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point const layers_len_at = w.n; try w.putU32(0); const layers_start = w.n; - if (layers.len != 0 or tags.len != 0) { + if (layers.len != 0 or tags.len != 0 or regions.len != 0) { var count: u16 = 0; for (layers) |layer| if (layer.rows != 0) { count += 1; @@ -717,7 +744,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layers) |layer| { if (layer.rows == 0) continue; if (layer.context_rows > limits.wrap_rows or layer.context_rows > layer.rows) return error.BadValue; - try w.putU16(layer.pane); + try w.putU16(layer.id); try w.putU32(layer.serial); try w.putU16(layer.viewport.x); try w.putU16(layer.viewport.y); @@ -731,13 +758,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layer.cells) |*cell| try putCell(&w, cell); } var tag_count: u16 = 0; - for (tags) |layer| if (layer.cols != 0) { + for (tags) |layer| if (layer.rows != 0) { tag_count += 1; }; try w.putU16(tag_count); for (tags) |layer| { - if (layer.cols == 0) continue; - if (layer.cells.len != layer.cols) return error.BadValue; + if (layer.rows == 0) continue; + if (layer.cells.len != @as(usize, layer.cols) * layer.rows) return error.BadValue; try w.putByte(@intFromEnum(layer.kind)); try w.putU16(layer.id); try w.putU32(layer.serial); @@ -746,10 +773,26 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(layer.viewport.w); try w.putU16(layer.viewport.h); try w.putU16(layer.cols); + try w.putU16(layer.rows); for (layer.bg) |channel| try w.putByte(channel); - try putCursor(&w, if (layer.cursor) |c| .{ .x = c.col, .y = 0, .bar = c.bar } else null, layer.cols, 1); + try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows); for (layer.cells) |*cell| try putCell(&w, cell); } + try w.putU16(@intCast(regions.len)); + for (regions) |region| { + try w.putByte(@intFromEnum(region.kind)); + try w.putByte(region.owner); + try w.putU32(region.serial); + try w.putU16(region.rect.x); + try w.putU16(region.rect.y); + try w.putU16(region.rect.w); + try w.putU16(region.rect.h); + try w.putBool(region.active); + try w.putU16(region.thumb_y); + try w.putU16(region.thumb_h); + try w.putByte(region.notice); + try w.putU16(region.clip); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1820,22 +1863,26 @@ test "detached wire carries compact body rows and exact pointer metrics" { var cells: [8]pardes.Cell = @splat(.{}); var logical: [12]pardes.Cell = @splat(.{}); logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; - var layer: pardes.BodyLayer = .{ .pane = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; + var layer: pardes.Layer = .{ .id = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; layer.context_separators.set(0); var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; - const tag: pardes.TagLayer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .col = 2, .bar = true } }; - const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag})); + const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; + const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}); + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; - var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}); - var tags: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}); + var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); + var tags: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}); defer for (&tags) |*item| gpa.free(item.cells); defer for (&decoded) |*item| gpa.free(item.cells); try frame.apply(&cells); - try frame.applyLayers(gpa, &decoded, &tags); + var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; + defer regions.deinit(gpa); + try frame.applyLayers(gpa, &decoded, &tags, ®ions); + try std.testing.expectEqualDeep(placed[0..], regions.items); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1847,10 +1894,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expectEqual(tag.id, tags[0].id); try std.testing.expectEqual(tag.bg, tags[0].bg); try std.testing.expectEqual(tag.cols, tags[0].cols); - try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.col); + try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.x); try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme()); - const pointer = layer.hitAt(4, 27, 8, 16, 6, 8).?; - const tag_pointer = tag.hitAt(11, 8, 8, 16, 4, 8).?; + const pointer = layer.bodyHit(4, 27, 8, 16, 6, 8).?; + const tag_pointer = tag.tagHit(11, 8, 8, 16, 4, 8).?; const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } }; var mouse_bytes: [256]u8 = undefined; const mouse_encoded = try encodeClient(&mouse_bytes, message); @@ -1861,19 +1908,20 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); try std.testing.expectEqual(@as(usize, 0), item.context_separators.count()); } for (tags) |item| { - try std.testing.expectEqual(@as(u16, 0), item.cols); + try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); } + try std.testing.expectEqual(@as(usize, 0), regions.items.len); const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7d91ad31..7cfe7554 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -28,7 +28,7 @@ const Pane = panes.Pane; const Text = panes.Text; const Rect = layout.Rect; const Region = @import("surface.zig").Region; -const TagKind = @import("tag_layer.zig").Kind; +const TagKind = @import("Layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; const BOX_H = pardes.BOX_H; @@ -40,8 +40,6 @@ const MAX_PANES = pardes.MAX_PANES; const MAX_COLS = pardes.MAX_COLS; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; -const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; -const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; /// Where every part of the frame goes, decided once before anything is /// painted: the page; each pane's grip, tag, body, rail (with its thumb) @@ -172,7 +170,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // never set. for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; - for (&s.tag_layers) |*layer| layer.cols = 0; + for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; @@ -505,11 +503,9 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { tagline.revealHeader(p); if (s.region(.workspace_tag, Region.none)) |region| { const top = if (p.header_focus and p.header_column == null) p.header_top else 0; - for (0..region.rect.h) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); - } + const layer = &s.tag_layers[MAX_PANES + MAX_COLS]; + try renderHeaderLayer(p, arena, layer, .workspace, 0, region.rect, tagline.topbar(p), top, chrome.tag_bg, chrome.tag_fg); + joinHeaderRows(s, layer, chrome.tag_fg); } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { @@ -531,23 +527,21 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); } const top = if (p.header_focus and p.header_column == c) p.header_top else 0; - for (0..column_rows) |line| { - const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(c), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, c), line + top), bg, fg); - if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); - // Laid out at its full width; only shown short of the rail. - s.tag_layers[index].viewport.w -= region.clip; - } + const layer = &s.tag_layers[MAX_PANES + c]; + try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg); + if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg); + // Laid out at its full width; only shown short of the rail. + layer.viewport.w -= region.clip; }; } -/// Copy a header line's layer into the grid under it, and its caret. -fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { +/// Copy a header's layer into the grid under it, row by row, and its caret. +fn joinHeaderRows(s: *Surface, layer: *const pardes.Layer, fg: [3]u8) void { const v = layer.viewport; const w = @min(v.w, s.cols -| v.x); - joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); + for (0..layer.rows) |line| joinCells(s, v.x, v.y + @as(u16, @intCast(line)), layer.cells[line * layer.cols ..][0..layer.cols], 0, w, .{ .rgb = fg }); if (layer.cursor) |caret| { - if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar }; + if (caret.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.y, .bar = caret.bar }; } } @@ -600,77 +594,85 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { return .{ .lo = start + basename, .hi = start + name.len }; } -/// One row of a header or a notice band as a tag layer: `text` is line -/// `line` of what it shows. -fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { +/// A header or a notice band as a tag layer: `viewport.h` rows, line `top` +/// of `text` and on, laid out at the tagline's pitch -- as wide as its +/// widest line and never narrower than its viewport. +fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void { _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; + if (viewport.w == 0 or viewport.h == 0) { + layer.rows = 0; return; } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + var widest: usize = 0; + for (0..viewport.h) |line| widest = @max(widest, panes.File.displayWidth(modal.lineSlice(text, line + top))); + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), widest + 1))); + const count = @as(usize, columns) * viewport.h; + if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); layer.kind = kind; layer.id = id; - layer.line = line; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; + layer.rows = viewport.h; + layer.context_rows = 0; layer.bg = bg; layer.cursor = null; layer.slide = 0; layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select // its text with the column tag's offsets and give it a second caret. const editing: ?*Text = if (p.header_focus and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, - .pane, .notice => false, + .pane, .notice, .body => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - // The word under the pointer: on a header even while it is edited, but - // not on a notice (a message is not a command line), nor on a column a - // carried column's pointer is aiming at a place over. - const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); - if (hovered) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } - if (editing) |t| { - if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + for (0..viewport.h) |index| { + const line: u16 = @intCast(index); + const shown = modal.lineSlice(text, index + top); + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] }; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + // The word under the pointer: on a header even while it is edited, + // but not on a notice (a message is not a command line), nor on a + // column a carried column's pointer is aiming at a place over. + const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); + if (hovered) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) + else if (p.hover_row == viewport.y + line and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(shown, panes.File.rawAtDisplay(shown, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { + var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } } + const caret = panes.File.rawDisplayCol(shown, @min(@as(usize, @intCast(@max(0, t.cur_col))), shown.len)) -| scroll; + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .x = @intCast(caret), .y = line, .bar = t.mode == .insert }; } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } } -/// A pane's tag rows, painted once into their layers, a layer to a line, -/// and copied into the grid; the grid's caret where the layer has it. +/// A pane's tag rows, painted once into their layer, a row to a line, and +/// copied into the grid; the grid's caret where the layer has it. fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET); // Too narrow for a layer: only the grid's gap cell. @@ -692,23 +694,26 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity); const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg }; - // A layer to a line: the first in the pane's own slot, the rest in the - // slots kept for the lines of taller tags. + // One layer, a row to each line the tag shows. + const layer = &s.tag_layers[id]; + const count = @as(usize, columns) * rows; + if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y, .w = region.rect.w - TAG_GAP - region.clip, .h = rows }; + layer.cols = columns; + layer.rows = rows; + layer.context_rows = 0; + layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.slide = 0; + layer.fade = 1; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + .{ .x = cursor.x - TAG_TEXT_INSET, .y = cursor.y, .bar = cursor.bar } + else + null; for (0..rows) |line| { - const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; + @memcpy(layer.cells[line * columns ..][0..columns], storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); // The grid's row: the gap cell and the text, as far as the pane. joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg); } @@ -765,9 +770,8 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); - const layer = &s.tag_layers[index]; + const layer = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; + try renderHeaderLayer(p, arena, layer, .notice, @intCast(id), region.rect, line, 0, bg, fg); layer.slide = motion.slide; layer.fade = motion.alpha; // The caret rides the band, where the prompt's glyphs are. @@ -775,7 +779,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) break :caret; - layer.cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + layer.cursor = .{ .x = @intCast(pad + typed - dropped), .y = 0, .bar = pane.input.mode == .insert }; } // The grid shows the chip's last cells of the band, where the text ends // (all of it where the tagline's pitch is the grid's). @@ -785,7 +789,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) // no ink of its own (the layer prints it as spaces in the band's ink). for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; if (layer.cursor) |caret| { - if (caret.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; + if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; } // Laid out at its full width; only shown short of the rail. layer.viewport.w -= region.clip; @@ -960,10 +964,11 @@ test "place puts every part where the painters and layers put it, in every layou try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role); const tag = s.region(.tag, id).?; try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect); - if (r.w > TAG_TEXT_INSET) for (0..pane.tag_rows) |line| { - const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }, layer.viewport); - }; + // One layer, a row to each line of the tag. + if (r.w > TAG_TEXT_INSET) { + try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r), .w = r.w - TAG_TEXT_INSET, .h = pane.tag_rows }, s.tag_layers[id].viewport); + try std.testing.expectEqual(pane.tag_rows, s.tag_layers[id].rows); + } if (pane.collapsed) { try std.testing.expect(s.region(.body, id) == null); try std.testing.expect(s.region(.rail, id) == null); diff --git a/src/edit.zig b/src/edit.zig index b2d71ed2..9044d7ed 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -17,7 +17,7 @@ const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Mouse = pardes.Mouse; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a1e21dc0..ff99bd39 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1779,8 +1779,8 @@ const Gui = struct { tagline_percent: u8, tagline_width: u32, tagline_height: u32, - attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null, - attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.TagLayer = null, + 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, @@ -3517,7 +3517,7 @@ fn gridCellAtDimensions( var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; 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.hitAt(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { + 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; } @@ -3529,19 +3529,19 @@ fn gridCellAtDimensions( 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.hitAt(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| { + 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.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { + 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.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + 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; } @@ -5203,18 +5203,18 @@ fn renderFrame( } } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += tagLayerCellCount(g, layer) + 1; + batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { const track = batch.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.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += tagLayerCellCount(g, layer) + 1; + destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); @@ -5303,7 +5303,7 @@ fn renderFrame( emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); } @@ -5312,7 +5312,7 @@ fn renderFrame( 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.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination: usize = if (under) 0 else batch_index; emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } @@ -5621,10 +5621,10 @@ test "compact column and pane underlays share focus tint without tinting the glo try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); } -fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const pardes.TagLayer { +fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; - if (layer.cols > 0 and row == r.y and col >= r.x and col - r.x < r.w) return layer; + 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; } @@ -5633,9 +5633,9 @@ fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const parde /// themselves: the grid's cell loops skip them. Filling each layer's rectangle /// once replaces scanning every layer for every cell, which was a third of the /// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers only its viewport's -/// first row, and a viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) void { +/// and tagLayerIn find a layer for: a tag layer covers its rows, and a +/// viewport past the grid covers the grid's part of it. +fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { @memset(cover, false); for (body_layers) |*layer| { const r = layer.viewport; @@ -5647,12 +5647,13 @@ fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes. for (tag_layers) |*layer| { const r = layer.viewport; const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.cols == 0 or r.y >= rows or r.x >= right) continue; - @memset(cover[@as(usize, r.y) * cols ..][r.x..right], true); + if (layer.rows == 0 or r.x >= right) continue; + var y: u32 = r.y; + while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); } } -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) !void { +fn expectCoverIsScan(cover: []const bool, 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]); @@ -5685,7 +5686,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand @memset(&cover, true); // what an earlier frame left coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); - for (s.tagLayers()) |layer| notices = notices or (layer.cols != 0 and layer.kind == .notice); + for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5702,8 +5703,8 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand }; var prng = std.Random.DefaultPrng.init(0x6c61796572); const random = prng.random(); - var body: [pardes.MAX_PANES]pardes.BodyLayer = undefined; - var tag: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = undefined; + var body: [pardes.MAX_PANES]pardes.Layer = undefined; + var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; var grid: [64 * 40]bool = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); @@ -5720,12 +5721,12 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } -fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 { +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.TagLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { +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); @@ -5735,20 +5736,25 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p // 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; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * 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; - emitInstance(g, instances, next.*, 0, layer.viewport.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); - if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); - next.* += 1; - const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null; - for (0..tagLayerCellCount(g, layer)) |col| { - const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page); - clipInstanceRight(&instances[next.*], right, win_w); + // 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); 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; + } } } @@ -5760,11 +5766,11 @@ fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { instance.y0 = @max(instance.y1, limit); } -fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { +fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.Layer { return bodyLayerIn(surface.bodyLayers(), col, row); } -fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const pardes.BodyLayer { +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; @@ -5788,7 +5794,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void { instance.y1 = limit; } -fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { +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); @@ -5851,11 +5857,11 @@ fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { return false; } -fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { +fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { const at = @as(usize, row) * cols + col; if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; for (layers) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or layer.viewport.y != row) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; if (col < x or col - x >= config.GUTTER) continue; const index = @as(usize, row) * cols + x; @@ -6607,7 +6613,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }}; + var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; @@ -7316,16 +7322,17 @@ fn buildOverlay( } }; if (transient_on) for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.col >= layer.cols) continue; + if (!cursor.bar or 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; - const x = left + @as(f32, @floatFromInt(cursor.col)) * width; + const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline))); + const y = layer.viewport.y + cursor.y; + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); + builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); }; if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); @@ -7506,7 +7513,7 @@ fn appendPaneChrome( // off from the text beneath it the way a tagline is: the same colour and // thickness along its bottom, running to the window edge when it does. for (surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) 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 (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; @@ -7528,21 +7535,25 @@ fn appendPaneChrome( fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (surface.tagLayers()) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - const y = layer.viewport.y; - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + // A mark on each of the tag's rows whose grip cell is blank: every + // row but the one with the mode's glyph. + for (0..layer.rows) |index| { + const y = layer.viewport.y + @as(u16, @intCast(index)); + if (x >= surface.cols or y >= surface.rows) continue; + const cell = &surface.cells[@as(usize, y) * surface.cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); + builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + } } } @@ -7553,8 +7564,8 @@ test "column grips stay unmarked while pane marks retain physical centering" { g.cell_h = 20; var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20 }; + surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; + surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; var vertices: [24]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); @@ -7927,7 +7938,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [24]pardes.Cell = @splat(.{}); - var layer: pardes.BodyLayer = .{ + var layer: pardes.Layer = .{ .viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 }, .cols = 2, .rows = 12, @@ -7967,7 +7978,7 @@ test "compact tag layers fill available width and clip at their physical edge" { g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [6]pardes.Cell = @splat(.{}); - const layer: pardes.TagLayer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .cells = &cells, .bg = .{ 20, 30, 40 } }; + 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); diff --git a/src/look.zig b/src/look.zig index 8f9e15f2..258cf8f5 100644 --- a/src/look.zig +++ b/src/look.zig @@ -21,7 +21,7 @@ const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const pathUnder = pardes.pathUnder; diff --git a/src/macos.zig b/src/macos.zig index fce0a53e..f7e03cd4 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1861,9 +1861,9 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.TagLayer) void { +fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.Layer) void { for (layers) |layer| { - if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; + if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { @@ -1879,7 +1879,7 @@ fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cel fn tagCursorCovered(core: *const pardes.Pardes) bool { const cursor = core.surface.cursor orelse return false; for (core.surface.tag_layers) |layer| { - if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and + if (layer.rows != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and cursor.x - layer.viewport.x < layer.viewport.w) return true; } return false; @@ -2121,7 +2121,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], 11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom), @@ -2139,7 +2139,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell { const st = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &st.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &st.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source); @@ -3110,7 +3110,7 @@ test "mac tag layer ABI preserves logical capacity and physical grip" { logical[11].text[0] = 'Z'; logical[11].len = 1; logical[11].default = false; - core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } }; + core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .rows = 1, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .x = 11, .y = 0, .bar = true } }; defer core.surface.tag_layers[0] = .{}; try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0)); try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10)); diff --git a/src/pardes.zig b/src/pardes.zig index 6946e123..f8087bdb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; pub const Region = @import("surface.zig").Region; +pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); @@ -247,21 +248,17 @@ pub const Pane = panes.Pane; const Text = panes.Text; pub const MAX_PANES = 16; -pub const BodyLayer = @import("body_layer.zig").Layer; -pub const RowMetrics = @import("body_layer.zig").Metrics; -pub const TagLayer = @import("tag_layer.zig").Layer; -pub const TagHit = @import("tag_layer.zig").Hit; -pub const TagKind = @import("tag_layer.zig").Kind; -/// Panes, then columns, then the workspace bar, then each pane's notice bands. +pub const Layer = @import("Layer.zig"); +pub const RowMetrics = Layer.Metrics; +pub const TagHit = Layer.TagHit; +pub const TagKind = Layer.Kind; /// A tag shows at most this many lines; past them it is cut, like a line /// too long for its width. pub const MAX_TAG_ROWS = 8; -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (MAX_TAG_ROWS - 1); +/// Panes, then columns, then the workspace bar, then each pane's notice +/// bands: a band to a tag, however many lines it has. +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; -/// Where the layers for a pane tag's second and later lines start. -pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max; -/// ...and those of a column's (by index) or the workspace's (after them). -pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1); pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -787,7 +784,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y); try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y); @@ -5832,15 +5829,14 @@ pub const Pardes = struct { pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; + if (layer.rows == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; if (layer.kind == .pane) { if (layer.id >= p.panes.len) return null; const pane = p.panes[layer.id] orelse return null; if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; - // A taller tag has a layer per line; the point is on one of them. - return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; + return layer.tagHit(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; } return null; } @@ -5858,14 +5854,14 @@ pub const Pardes = struct { } pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { - const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; + const layer: Layer = .{ .kind = .pane, .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; - return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); + return layer.bodyHit(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); } return null; } @@ -6111,12 +6107,12 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch // A pixel shell draws the prompt from its band, in the tagline's pitch, // with the caret right after the marker's last glyph. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; try std.testing.expectEqual(row, band.viewport.y); const caret = band.cursor orelse return error.MissingBandCaret; try std.testing.expect(caret.bar); - try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme()); + try std.testing.expectEqualStrings("/", band.cells[caret.x - 1].grapheme()); // The chip covers only its own columns. Left of it is ordinary body, and // a sweep there still selects. @@ -6162,11 +6158,11 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(p.surface.tag_layers[0].cursor == null); // The band carries the answer and the caret, one cell back from its end. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; const caret = band.cursor orelse return error.MissingBandCaret; - try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); - try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); + try std.testing.expectEqualStrings("t", band.cells[caret.x - 1].grapheme()); + try std.testing.expectEqualStrings("w", band.cells[caret.x].grapheme()); const grid = p.surface.cursor orelse return error.MissingPromptCursor; try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y); diff --git a/src/surface.zig b/src/surface.zig index fec43e30..db7dd468 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -11,8 +11,7 @@ const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const image = @import("image.zig"); const MAX_PANES = pardes.MAX_PANES; -const BodyLayer = @import("body_layer.zig").Layer; -const TagLayer = @import("tag_layer.zig").Layer; +const Layer = @import("Layer.zig"); const MAX_TAG_LAYERS = pardes.MAX_TAG_LAYERS; const CellPixels = pardes.CellPixels; @@ -380,8 +379,8 @@ pub const Surface = struct { /// paint the quiet affordance can carry the hover bit through the ABI /// without every selection painter learning a new parameter. mark_hover: bool = false, - body_layers: [MAX_PANES]BodyLayer = @splat(.{}), - tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), + body_layers: [MAX_PANES]Layer = @splat(.{}), + tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, @@ -394,15 +393,15 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, - previous_body_layers: []const BodyLayer = &.{}, - previous_tag_layers: []const TagLayer = &.{}, + previous_body_layers: []const Layer = &.{}, + previous_tag_layers: []const Layer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, - pub fn tagLayers(s: *const Surface) []const TagLayer { + pub fn tagLayers(s: *const Surface) []const Layer { return &s.tag_layers; } - pub fn bodyLayers(s: *const Surface) []const BodyLayer { + pub fn bodyLayers(s: *const Surface) []const Layer { return &s.body_layers; } diff --git a/src/tag_layer.zig b/src/tag_layer.zig index 7cfe5bbf..213ebc07 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -1,83 +1,10 @@ -//! Tag text has its own horizontal pitch. Its viewport stays in physical -//! body-grid coordinates so compact text never changes pane hit targets. +//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one +//! that left its band back onto it. const std = @import("std"); const pardes = @import("pardes.zig"); - -/// `notice` is a pane's transient line -- a builtin's name, a pending leader -/// chord -- carried as a tagline-shaped band so it gets the tagline's height, -/// small-font metrics, band offset and border instead of being a body-grid row -/// wearing a tagline font. -pub const Kind = enum(u8) { workspace, column, pane, notice }; -pub const Hit = struct { - kind: Kind, - id: u16, - serial: u32, - col: u16, - /// Which line of a taller tag the layer draws. - line: u16 = 0, - pixel_x: f32, - pixel_y: f32, - metrics: pardes.RowMetrics, -}; -pub const Layer = struct { - kind: Kind = .workspace, - id: u16 = 0, - serial: u32 = 0, - /// A pane's tag taller than a line is drawn a layer to a line; this is - /// which one, counted from its first. - line: u16 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - bg: [3]u8 = .{ 0, 0, 0 }, - cells: []pardes.Cell = &.{}, - cursor: ?struct { col: u16, bar: bool = false } = null, - /// Vertical offset in rows (negative: above its viewport), for a band - /// that is moving into place. A pixel shell draws it offset and clipped - /// to the rows below the viewport's top; the grid has no such thing. - slide: f32 = 0, - /// How present a notice band is (1 = fully) while it fades up or - /// dissolves. `bg` and the cells are already blended by it; a pixel - /// shell uses it for what it draws around the band, like its rule. - fade: f32 = 1, - - pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { - return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Hit { - if (layer.cols == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| - if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - return .{ - .kind = layer.kind, - .id = layer.id, - .serial = layer.serial, - .line = layer.line, - .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))), - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "tag text capacity uses its own pitch and excludes the physical grip" { - const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90 }; - try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); - try std.testing.expect(layer.hitAt(119.99, 85, 10, 20, 6, 12) == null); - try std.testing.expectEqual(@as(u16, 0), layer.hitAt(120, 85, 10, 20, 6, 12).?.col); - try std.testing.expectEqual(@as(u16, 50), layer.hitAt(423, 85, 10, 20, 6, 12).?.col); - try std.testing.expect(layer.hitAt(500, 85, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); -} +const Layer = @import("Layer.zig"); +const Hit = Layer.TagHit; +const Kind = Layer.Kind; /// Whether two pointer hits name the same cell of the same tag line. pub fn sameCell(a: ?Hit, b: ?Hit) bool { @@ -92,13 +19,13 @@ pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, var point = supplied orelse return null; if (point.kind != kind or point.id != id) return null; if (clamp) for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue; + if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue; const bw: f32 = @floatFromInt(point.metrics.body_w); const bh: f32 = @floatFromInt(point.metrics.body_h); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh; break; }; return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; diff --git a/src/web.zig b/src/web.zig index 532e3794..ac0ecbbf 100644 --- a/src/web.zig +++ b/src/web.zig @@ -224,13 +224,13 @@ export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_a var hit: ?pardes.Mouse.BodyHit = null; for (&s.core.surface.body_layers) |*layer| { const point = source orelse break; - hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + hit = layer.bodyHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (hit != null) break; } var tag_hit: ?pardes.TagHit = null; for (&s.core.surface.tag_layers) |*layer| { const point = source orelse break; - tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + tag_hit = layer.tagHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (tag_hit != null) break; } mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit); @@ -341,6 +341,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_TAG_LAYERS) return 0; const layer = &s.core.surface.tag_layers[index]; + if (layer.rows == 0) return 0; return switch (field) { 0 => layer.cols, 1 => @intFromEnum(layer.kind), @@ -350,9 +351,12 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], + // A band has a row to each line of its tag: `cols * rows` cells. + 11 => layer.rows, + 12 => if (layer.cursor) |c| c.y else 65535, else => 0, }; } @@ -361,7 +365,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { const s = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &s.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &s.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source); diff --git a/src/web/app.mjs b/src/web/app.mjs index 280239d5..03829178 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -64,13 +64,16 @@ export class DomRenderer { if (!val(0)) continue; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const textInset = this.wasm.pardes_tag_text_inset?.() ?? gripCols; - if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { - const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; - for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(val(5)*cols+x,offset); - if (val(1) === 2) paneIndicators.add(val(5)*cols+val(4)-textInset); - } - for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { - if (val(5) < rows) tagCovered[val(5) * cols + x] = 1; + // A row to each line of a taller tag. + for (let y = val(5); y < val(5) + Math.max(1, val(11)); y++) { + if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { + const offset = this.wasm.pardes_tagline_band_offset(y,rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; + for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(y*cols+x,offset); + if (val(1) === 2) paneIndicators.add(y*cols+val(4)-textInset); + } + for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { + if (y < rows) tagCovered[y * cols + x] = 1; + } } } } @@ -118,27 +121,35 @@ export class DomRenderer { const nodes = []; const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight; for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { - const values = Array.from({length:11}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); - const cols = values[0]; + const layerValues = Array.from({length:13}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); + const cols = layerValues[0]; if (!cols) continue; const ptr = this.wasm.pardes_tag_layer_cells(i); if (!ptr) continue; - const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + // A row to each line of a taller tag, each drawn as a band of its own. + for (let line = 0; line < Math.max(1, layerValues[11]); line++) { + const key = i*64+line; + const values = layerValues.slice(0,11); + values[5] = layerValues[5] + line; + values[7] = 1; + values[8] = layerValues[12] === line ? layerValues[8] : 65535; + const bytes = new Uint8Array(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols); const gripCell = canonical[values[5]*this.cols+gripX]; const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1; const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":"); - const cached = this.tagNodes.get(i); + const cached = this.tagNodes.get(key); if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) { nodes.push(cached.node); if (cached.grip) nodes.push(cached.grip); continue; } - const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + const view = new DataView(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const layer = this.root.ownerDocument.createElement("div"); layer.dataset.tagLayer = String(i); + layer.dataset.tagLine = String(line); layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`; for (let col = 0; col < cols; col++) { const off = col*CELL_SIZE; @@ -171,9 +182,10 @@ export class DomRenderer { gripNode.append(mark); } } - this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); + this.tagNodes.set(key,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); nodes.push(layer); if (gripNode) nodes.push(gripNode); + } } if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes); } diff --git a/test/column_drag.zig b/test/column_drag.zig index feea1bf7..3aea4286 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -8,17 +8,17 @@ fn mouse(p: *Pardes, kind: pardes.Mouse.Kind, col: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = kind, .col = col, .row = pardes.TOPBAR_H } }); } -fn tagLayer(p: *Pardes, column: usize) !*pardes.TagLayer { +fn tagLayer(p: *Pardes, column: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == .column and layer.id == column) return layer; + if (layer.rows > 0 and layer.kind == .column and layer.id == column) return layer; } return error.MissingColumnTagLayer; } -fn headerMouse(layer: *const pardes.TagLayer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { +fn headerMouse(layer: *const pardes.Layer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(display_col)) + 0.5) * 5; const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } const Snapshot = struct { @@ -144,7 +144,7 @@ test "column grip is separate from compact text and the resize seam" { try std.testing.expectEqual(p.col_x[column], rect.x); try std.testing.expectEqual(rect.x + pardes.TAG_TEXT_INSET, (try tagLayer(p, column)).viewport.x); for (p.surface.tagLayers()) |pane_layer| { - if (pane_layer.cols > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) + if (pane_layer.rows > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) try std.testing.expectEqual((try tagLayer(p, column)).viewport.x, pane_layer.viewport.x); } const pane_tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; diff --git a/test/panes.zig b/test/panes.zig index 11fb750a..5e26cdd0 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -19,17 +19,17 @@ fn setTag(pane: *Pane, text: []const u8) !void { } const TagLayerTests = struct { - const Kind = @FieldType(pardes.TagLayer, "kind"); - fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.TagLayer { + const Kind = @FieldType(pardes.Layer, "kind"); + fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == kind and layer.id == id) return layer; + if (layer.rows > 0 and layer.kind == kind and layer.id == id) return layer; } return error.MissingTagLayer; } - fn mouse(layer: *const pardes.TagLayer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } test "compact tag layers render and edit Unicode text beyond body column capacity" { @@ -200,14 +200,16 @@ test "a tag with a newline in it is as tall as its lines, and each line is typed const body_y = p.bodyTop(pane, r); try std.testing.expectEqual(if (bottom) r.y else r.y + 2, body_y); try std.testing.expectEqualStrings("b", surface.at(r.x + config.GUTTER + panes.File.gutterWidth(pane), body_y).grapheme()); - // a layer to a line, for a pixel shell - var lines: usize = 0; + // one layer, a row to each line, for a pixel shell + var layers: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.id != 0) continue; - try std.testing.expectEqual(tag_y + layer.line, layer.viewport.y); - lines += 1; + if (layer.rows == 0 or layer.kind != .pane or layer.id != 0) continue; + try std.testing.expectEqual(tag_y, layer.viewport.y); + try std.testing.expectEqual(@as(u16, 2), layer.rows); + try std.testing.expectEqual(@as(u16, 2), layer.viewport.h); + layers += 1; } - try std.testing.expectEqual(@as(usize, 2), lines); + try std.testing.expectEqual(@as(usize, 1), layers); // a click on the second line puts the tag's cursor on it p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.{ .key = .{ .cp = ':' } }); @@ -541,11 +543,11 @@ const TagNavigationTests = struct { }; const BodyHitTests = struct { - fn mouse(layer: *const pardes.BodyLayer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const width: f32 = if (row < layer.context_rows) 8 else 10; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * width; const y = layer.rowTop(row, 20, 10) + layer.rowHeight(row, 20, 10) / 2; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.hitAt(x, y, 10, 20, 8, 10).? }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.bodyHit(x, y, 10, 20, 8, 10).? }; } test "compact TreeContext mouse follows source rows in reclaimed space" { @@ -673,7 +675,7 @@ const TreeContextTests = struct { const y = layer.rowTop(row, 20, 10) + 9.5; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + 5; // A one-pixel separator remains inside its existing header row. - try std.testing.expectEqual(row, layer.hitAt(x, y, 10, 20, 8, 10).?.row); + try std.testing.expectEqual(row, layer.bodyHit(x, y, 10, 20, 8, 10).?.row); } pane.scrollBy(-5); _ = try p.render(frame.allocator()); -- cgit v1.3 From 360b72ade0acfa655a3740b638a0cc8e23238a05 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:59:21 -0300 Subject: The GUI draws in tiers from the regions; pane chrome moves with its pane Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig. --- build.zig | 1 + docs/render-pipeline.md | 14 +- shaders/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.spv | Bin 0 -> 2100 bytes shaders/prebuilt/ui.frag.glsl | 3 + shaders/prebuilt/ui.frag.spv | Bin 4668 -> 5700 bytes shaders/prebuilt/ui.vert.glsl | 6 + shaders/prebuilt/ui.vert.spv | Bin 5468 -> 5612 bytes shaders/ui.frag.glsl | 3 + shaders/ui.vert.glsl | 6 + src/Presentation.zig | 5 + src/detached/client.zig | 4 +- src/detached/server.zig | 1 + src/detached/wire.zig | 41 +- src/draw.zig | 63 +- src/gui/gui.zig | 1476 +++++++++++++++++++------------------- src/pardes.zig | 1 + src/surface.zig | 40 +- test/gui-goldens.txt | 11 +- test/gui_golden.py | 18 +- 21 files changed, 978 insertions(+), 763 deletions(-) create mode 100644 shaders/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.spv (limited to 'src/detached/server.zig') diff --git a/build.zig b/build.zig index 666e792d..803f773d 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,7 @@ pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", + "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index a8b3f019..69645c96 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`) replaces the vertical transition's scissor. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | @@ -845,13 +845,21 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. + layer then. Carried forward from stage 8 (a GUI-only stage) to the hover + change after it, which also retires `mark_hover`: the affordance is a + core paint every shell and snapshot sees. 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The context-row path also still builds the body's text twice (a pre-pass with body_rows 0 decides the context rows, renderBody builds it again at - the layer's rows); the paint is single. Measure both there. + the layer's rows); the paint is single. Measure both there. Decided at + stage 8, measured (Tracy, 200x60, TreeContext on a scrolled source, + ReleaseFast): the pre-pass text build is 5 µs median and the layer's + copies 10 µs per context-row body per frame. Kept as they are: nothing in + the GUI reads a layerless body from anything but the grid, which is + exact, so a layer for every body would be ~10 µs a body a frame for no + pixel. ## 15. Questions for the user diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/prebuilt/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.spv b/shaders/prebuilt/decor.frag.spv new file mode 100644 index 00000000..7e162f5a Binary files /dev/null and b/shaders/prebuilt/decor.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 120438e7..033c10ab 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index 659049dd..619a9690 100644 Binary files a/shaders/prebuilt/ui.vert.spv and b/shaders/prebuilt/ui.vert.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/src/Presentation.zig b/src/Presentation.zig index e1f1b585..3b5bb3e0 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -44,6 +44,8 @@ pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), +previous_regions: [pardes.MAX_REGIONS]pardes.Region = undefined, +previous_nregions: u16 = 0, previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -346,6 +348,8 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } @memcpy(self.previous_cells, cells); + @memcpy(self.previous_regions[0..surface.nregions], surface.regionList()); + self.previous_nregions = surface.nregions; self.previous_cols = surface.cols; self.previous_rows = surface.rows; self.previous_valid = true; @@ -584,6 +588,7 @@ pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { s.previous_cells = self.previous_cells; s.previous_body_layers = &self.previous_body_layers; s.previous_tag_layers = &self.previous_tag_layers; + s.previous_regions = self.previous_regions[0..self.previous_nregions]; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( diff --git a/src/detached/client.zig b/src/detached/client.zig index 3347f609..9fa85fb4 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -107,6 +107,8 @@ pub const Client = struct { tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), /// The session's placed regions, as its last frame gave them. regions: std.ArrayListUnmanaged(pardes.Region) = .empty, + /// The palette its chrome is drawn in, as its last frame gave it. + chrome: pardes.Chrome = .{}, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -322,7 +324,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions, &c.chrome) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; diff --git a/src/detached/server.zig b/src/detached/server.zig index 46fb8fcf..9e18e125 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -760,6 +760,7 @@ pub const Session = struct { &surface.body_layers, &surface.tag_layers, surface.regionList(), + surface.chrome, ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 684c869b..32cd5229 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -169,8 +169,9 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void { regions.clearRetainingCapacity(); + chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -188,6 +189,8 @@ pub const Frame = struct { gpa.free(layer.cells); layer.* = .{}; } + regions.clearRetainingCapacity(); + chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); @@ -255,6 +258,15 @@ pub const Frame = struct { region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; regions.appendAssumeCapacity(region); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = &@field(chrome, field.name); + if (field.type == ?[3]u8) { + if (try r.getBool()) { + rgb.* = .{ 0, 0, 0 }; + for (&rgb.*.?) |*channel| channel.* = try r.getByte(); + } + } else for (rgb) |*channel| channel.* = try r.getByte(); + } try r.end(); } @@ -697,7 +709,7 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{}); } pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { @@ -708,12 +720,15 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg for (tags) |layer| if (layer.rows != 0) { n += 28 + layer.cells.len * cell_max; }; - return n + regions.len * region_len; + return n + regions.len * region_len + chrome_len; } const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; +/// The palette: each colour three bytes, a theme's optional ones a flag +/// before them. +const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -793,6 +808,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putByte(region.notice); try w.putU16(region.clip); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = @field(chrome, field.name); + if (field.type == ?[3]u8) { + try w.putBool(rgb != null); + if (rgb) |value| for (value) |channel| try w.putByte(channel); + } else for (rgb) |channel| try w.putByte(channel); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1871,7 +1893,8 @@ test "detached wire carries compact body rows and exact pointer metrics" { const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); @@ -1881,8 +1904,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try frame.apply(&cells); var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; defer regions.deinit(gpa); - try frame.applyLayers(gpa, &decoded, &tags, ®ions); + var chrome: pardes.Chrome = .{}; + try frame.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqualDeep(placed[0..], regions.items); + try std.testing.expectEqualDeep(palette, chrome); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1908,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions, &chrome)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); @@ -1922,6 +1947,6 @@ test "detached wire carries compact body rows and exact pointer metrics" { const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags, ®ions); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7cfe7554..4242a6e7 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -47,14 +47,7 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; /// painters below and every shell read these rectangles. pub fn place(p: *Pardes, s: *Surface) void { s.nregions = 0; - const add = struct { - fn region(surface: *Surface, r: Region) void { - std.debug.assert(surface.nregions < surface.regions.len); - surface.regions[surface.nregions] = r; - surface.nregions += 1; - } - }.region; - add(s, .{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); + s.addRegion(.{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; Messages.collectNotices(p, pane, p.rects[id], id); @@ -64,12 +57,12 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); - add(s, .{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - add(s, .{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. @@ -93,7 +86,7 @@ pub fn place(p: *Pardes, s: *Surface) void { rail.thumb_y = @intCast(@min(pos, track_h)); rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); } - add(s, rail); + s.addRegion(rail); // Each notice a chip at the right edge, one to a row in the order // collectNotices chose, as wide as its own message and no wider, so // the text beneath the rest of the row keeps reading as the file. @@ -106,7 +99,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const chip = Messages.noticeCols(p, text, tw); if (chip == 0) continue; pane.notices.left[i] = tx + tw - chip; - add(s, .{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); + s.addRegion(.{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); } } const column_rows = p.columnBarHeight(); @@ -114,11 +107,16 @@ pub fn place(p: *Pardes, s: *Surface) void { if (p.col_w[c] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == c else false; const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - add(s, .{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); - add(s, .{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); + s.addRegion(.{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); + s.addRegion(.{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); }; + const active_col = if (layout.findPane(p, p.active)) |found| found.col else null; + for (0..p.ncol) |c| { + if (p.col_w[c] == 0) continue; + s.addRegion(.{ .kind = .column, .owner = @intCast(c), .serial = p.col_serial[c], .active = active_col == c, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = p.col_w[c], .h = s.rows -| p.topBarHeight() } }); + } if (p.settings.workspace_tag) - add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); + s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); // A column carried to the window's right edge shows where it lands as a // rail in the last cell: the layers under it stop a cell short. if (p.drag == .column_move) rail: { @@ -176,8 +174,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; + s.previous_regions = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); + s.chrome = .{ + .page = p.theme().bg, + .fg = p.theme().fg, + .border = chrome.border, + .topbar_rule = config.gui_topbar_pane_border_rgb orelse chrome.border, + .tag_bg = chrome.tag_bg, + .tag_focus_bg = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg, + .tag_fg = chrome.tag_fg, + .scroll_track = chrome.scroll_track, + .scroll_thumb = chrome.scroll_thumb, + }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; @@ -203,16 +213,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (d.cur_x < s.cols) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } if (d.corner) |k| if (d.cur_y < s.rows) { - var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + const first = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + var col = first; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } }); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; @@ -232,13 +246,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // color: this is where the grabbed column lands. const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); var row: u16 = p.topBarHeight(); + // A guide region for each run of it the tags do not break. + var run: u16 = row; while (row < s.rows) : (row += 1) { // Past the left edge's scroll rail the rail would cut // through column 0's grip and pane boxes: leave them whole. - if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; + if (edge == 0 and s.at(x, row).style.font_role == .tagline) { + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); + run = row + 1; + continue; + } s.overlayDash(x, row, "╎"); s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; } + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { @@ -247,6 +268,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } }); } }, .none, .select => if (p.pointer_inside) { @@ -256,6 +279,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } } for (0..p.ncol) |cc| { @@ -264,6 +288,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } }); } } for (0..p.ncol -| 1) |cn| { @@ -272,6 +297,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } }); } }, } @@ -309,6 +335,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { + s.addRegion(.{ .kind = .debug, .rect = .{ .x = ox, .y = p.topBarHeight(), .w = ow, .h = oh } }); // vaxis single_rounded border ring, default style s.set(ox, p.topBarHeight(), "╭", .{}); s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ff99bd39..35fbc99d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -21,6 +21,7 @@ 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"); @@ -37,6 +38,7 @@ 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"); @@ -443,6 +445,11 @@ const CellInstance = extern struct { 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 { @@ -545,7 +552,8 @@ test "saved native placement retains identity and geometry but no producer bytes } test "shader instance ABI carries aligned transition vectors" { - try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance)); + 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")); @@ -560,12 +568,17 @@ test "shader instance ABI carries aligned transition vectors" { .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; - const plan = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 4), plan.len); - try std.testing.expect(plan.batches[0].track == null); - try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane); - try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane); - try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane); + // 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" { @@ -573,11 +586,11 @@ test "GUI paint plan snaps history effects without a frozen grid" { .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; - try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); - const ready = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase); + 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" { @@ -588,10 +601,10 @@ test "closing tombstone overlays but never owns canonical cells" { .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; - const plan = makePaintPlan(&.{closing}, true); - try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); - try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase); + const groups = makeGroups(&.{closing}, true); + try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); + 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 { @@ -1745,6 +1758,7 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -1764,9 +1778,11 @@ const Gui = struct { 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 by layers (coverLayers): this frame's - /// layers, then the layers a panel transition is leaving. - layer_cover: std.ArrayListUnmanaged(bool) = .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 = &.{}, @@ -1996,47 +2012,127 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { const boxContains = pardes.animation.Box.contains; -const PaintBatch = struct { +/// 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, }; -const PaintPlan = struct { - batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), - len: usize = 1, +/// 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 makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan { - var plan: PaintPlan = .{}; - for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| { - for (tracks) |track| { - if (!track.active() or track.phase != phase) continue; - if (track.effect.needsPreviousGrid() and !has_diff) continue; - std.debug.assert(plan.len < plan.batches.len); - plan.batches[plan.len].track = track; - plan.len += 1; - } +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; } - return plan; + groups.notices = groups.tracks + 1; + groups.guides = groups.tracks + 2; + groups.ink = groups.tracks + 3; + groups.len = groups.tracks + 4; + return groups; } -fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| { - if (batch.track.?.phase == .closing) continue; - if (boxContains(batch.track.?.to, col, row)) return index; +/// 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 paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| - if (batch.track.?.serial == serial) return index; +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; } +/// 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, + _: u7 = 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 => { + 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.active) mark.rect(cover, cols, rows, region.rect, "focus", 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))); @@ -2101,6 +2197,15 @@ fn setTransitionFields( 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; @@ -2276,7 +2381,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u 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); + const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + 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 crt_pipeline = try makeCrtPipeline(device, swapchain_format); @@ -2306,6 +2412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, @@ -2343,6 +2450,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u // regrown to; the image ones are released below. defer { c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); + c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -2368,7 +2476,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); - g.layer_cover.deinit(gpa); + g.cover.deinit(gpa); + g.decor.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2479,6 +2588,7 @@ fn localSession( .watches = &watches, .fs = fs, .test_mode = test_mode, + .test_clock = if (test_mode) host_io.testClock() else null, }; defer shell.shutdownPtys(); const host = shell.host(); @@ -2668,24 +2778,12 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { - 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, - }; - const chrome = frameChromeBg(&surface); + var surface = attachedSurface(client); _ = renderFrame( g, gpa, null, &surface, - null, - chrome, - chrome, .{}, false, ) catch |err| blk: { @@ -2694,17 +2792,67 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { - if (surface.rows == 0 or surface.cols == 0) return bg_default; - for (surface.cells[0..surface.cols]) |cell| { - if (cell.default or cell.style.font_role != .tagline) continue; - return switch (cell.style.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - } - return bg_default; +/// 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 { @@ -3708,6 +3856,8 @@ const Shell = struct { 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, fn reap(s: *Shell) void { const now = shellClock(); @@ -3898,13 +4048,31 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { 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 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.gamepad != null or s.test_mode or core.needs_frame or retiring; + 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. - const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); + // The core's wake ran out: a virtual clock moves exactly there. + if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { + 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 @@ -4044,9 +4212,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { s.gpa, core, frame, - core.theme().bg, - config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border, - core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { @@ -4078,8 +4243,8 @@ fn postPresent(ctx: ?*anyopaque) void { s.core.acknowledgePanelPresentation(frame.panelTracks()); } -fn guiNow(_: ?*anyopaque) u64 { - return c.SDL_GetTicksNS(); +fn guiNow(ctx: ?*anyopaque) u64 { + return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4846,7 +5011,7 @@ fn prepareNativeImages( gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, - plan: *PaintPlan, + groups: *Groups, sw: u32, sh: u32, ) bool { @@ -4872,9 +5037,9 @@ fn prepareNativeImages( } g.prepared_images.clearRetainingCapacity(); - for (plan.batches[0..plan.len], 0..) |*batch, batch_index| { - batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - const track = batch.track; + 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) { @@ -4894,7 +5059,7 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or - paintBatchForSerial(plan, current.serial) != batch_index) continue; + 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, @@ -4903,7 +5068,7 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, current_track, null, false); } } - if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { + 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| { @@ -4912,7 +5077,7 @@ fn prepareNativeImages( } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - batch.image_count = image_end - batch.image_start; + group.image_count = image_end - group.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; @@ -4981,11 +5146,10 @@ fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, - plan: *const PaintPlan, - batch_index: usize, + batch: Group, + whole: c.SDL_Rect, ) void { const pass = rp orelse return; - const batch = plan.batches[batch_index]; 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; @@ -5004,12 +5168,8 @@ fn drawNativeImagesGpu( idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); - const whole = c.SDL_Rect{ - .x = 0, - .y = 0, - .w = @intCast(@as(u32, surface.cols) * g.cell_w), - .h = @intCast(@as(u32, surface.rows) * g.cell_h), - }; + // 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); } @@ -5018,9 +5178,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - theme_bg: ?[3]u8, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { @@ -5084,45 +5241,39 @@ fn renderFrame( }; 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); - var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - var overlay_count = buildOverlay( - g, - core, - surface, - layout, - sw, - sh, - topbar_pane_border_rgb, - tagline_rgb, - paint_plan.len == 1, - debug_on, - ); + const chrome = &surface.chrome; + var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); + // Cursors stand still while panes move, as they always have. + const cursors_shown = groups.tracks == 0; + 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, &paint_plan, sw, sh)) { + if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) { g.prepared_images.clearRetainingCapacity(); - for (paint_plan.batches[0..paint_plan.len]) |*batch| { - batch.image_start = 0; - batch.image_count = 0; + 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.layer_cover.resize(gpa, @as(usize, cells) * 2); - const covered = g.layer_cover.items[0..cells]; - coverLayers(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers()); + 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.layer_cover.items[cells..]; - if (paint_plan.len > 1) - coverLayers(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers); + 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(theme_bg, g.transparent); + const page = ground(chrome.page, g.transparent); const background_opacity = backgroundOpacity(g.applied_window_opacity); - const tagline_base = pardes.Cell{ - .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, - .default = false, - }; 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 .{ @@ -5139,28 +5290,21 @@ fn renderFrame( while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - const logical_idx: u32 = @as(u32, row) * surface.cols + col; + 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[logical_idx]) + cellInstanceCount(surface.at(col, row)) + cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at]) else - cellInstanceCount(surface.at(col, row)); - paint_plan.batches[batch_index].cell_count = std.math.add( - u32, - paint_plan.batches[batch_index].cell_count, - instance_count, - ) catch return error.GpuCapacity; + 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 (paint_plan.batches[1..paint_plan.len]) |*batch| { - const track = batch.track.?; + 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; @@ -5168,33 +5312,29 @@ fn renderFrame( 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]) continue; - const destination = if (duplicate_under) &paint_plan.batches[0] else batch; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - destination.cell_count = std.math.add( - u32, - destination.cell_count, - cellInstanceCount(&surface.previous_cells[logical_idx]), - ) catch return error.GpuCapacity; + 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 batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; + 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; - batch.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); + group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + 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 = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; + 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; @@ -5204,28 +5344,28 @@ fn renderFrame( } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + const group_index = if (layer.kind == .notice) groups.notices else groupAt(&groups, layer.viewport.x, layer.viewport.y); + groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + 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; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } - var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); + var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0); var cell_total: u32 = 0; - for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| { - batch.cell_start = cell_total; - cell_next[batch_index] = cell_total; - cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; - } - if (cell_total != 0) { - const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; + 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) { + const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { @@ -5239,45 +5379,40 @@ fn renderFrame( 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) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - if (data_diff) { - emitSurfaceCell( - g, - core, - instances, - &cell_next[batch_index], - col, - row, - layout, - win_w, - win_h, - track, - surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], - col, - null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), - true, - false, - page, - ); - } + 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, - core, + chrome, instances, - &cell_next[batch_index], + &cell_next[group_index], col, row, layout, @@ -5285,12 +5420,11 @@ fn renderFrame( win_h, if (data_effect and !data_diff) null else track, line, + line_cover, col, line_cursor, - &tagline_base, - paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row), false, - paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -5299,36 +5433,36 @@ fn renderFrame( // is a tag layer, and instances paint in emission order. for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); + 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 batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); + 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) groups.notices else track_index; + emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + 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; - const destination: usize = if (under) 0 else batch_index; - emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); + 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 (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + 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: usize = if (under) 0 else batch_index; - emitBodyLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, topbar_pane_border_rgb); + 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 (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + 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; @@ -5336,13 +5470,12 @@ fn renderFrame( 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]) continue; - const destination: usize = if (duplicate_under) 0 else batch_index; + if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue; emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[destination], + &cell_next[if (duplicate_under) 0 else index], col, row, layout, @@ -5350,10 +5483,9 @@ fn renderFrame( 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, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), false, false, page, @@ -5361,57 +5493,56 @@ fn renderFrame( } } } - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| - std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count); + 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) * @sizeOf(CellInstance) }; + 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) }; - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { - const has_shifted = batch_index == 0 and shifted != 0; - if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) { + const decor_base = cell_total + shifted; + for (groups.items[0..groups.len], 0..) |group, index| { + const has_shifted = index == 0 and shifted != 0; + if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, 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 (batch.cell_count != 0) { - const panel_clipped = if (batch.track) |active| active.effect == .vertical else false; - if (panel_clipped) { - const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh); - c.SDL_SetGPUScissor(rp, &clip); - } - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = batch.cell_start * @sizeOf(CellInstance), - }; + 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, batch.cell_count, 0, 0); - if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); + 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), - }; + 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 (batch.image_count != 0) - drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index); + if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); + if (group.decor_count != 0 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) * @sizeOf(CellInstance) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + } } drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); @@ -5455,23 +5586,23 @@ fn renderFrame( } if (g.capture) { command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh); + try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } if (g.soft_present) { command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); } return submitted; } @@ -5487,24 +5618,6 @@ const ResolvedCell = struct { const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; -fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 { - if (top == 0 or surface.rows <= top) return null; - const height = topbarPaneBorderPixels(cell_h, tagline_h); - if (height == 0) return null; - const base = @as(usize, top) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return height; - return null; -} - -fn bottomTaglinePresent(surface: *const pardes.Surface) bool { - if (surface.rows == 0) return false; - const base = @as(usize, surface.rows - 1) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return true; - return false; -} - test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; @@ -5587,40 +5700,6 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 { return if (cellFontRole(cell) == .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 >= core.topBarHeight() and row < core.topBarHeight() + 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 pane = core.panes[core.active] orelse return chrome.tag_bg; - const tag_y = core.tagTop(pane, r); - return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows 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 tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; @@ -5629,34 +5708,10 @@ fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.L return null; } -/// Marks the grid cells a body or tag layer covers, which the layers paint -/// themselves: the grid's cell loops skip them. Filling each layer's rectangle -/// once replaces scanning every layer for every cell, which was a third of the -/// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers its rows, and a -/// viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { - @memset(cover, false); - for (body_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + r.h)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } - for (tag_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } -} - -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { +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]); + try std.testing.expectEqual(scan, cover[row * cols + col].layer); }; } @@ -5670,7 +5725,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand 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]bool = undefined; + var cover: [97 * 31]Cover = undefined; var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; @@ -5683,9 +5738,20 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows); - @memset(&cover, true); // what an earlier frame left - coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + @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); @@ -5705,18 +5771,18 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand 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]bool = 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.* = .{ .cols = @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, random.boolean()); - coverLayers(cells, cols, rows, bodies, tags); + @memset(cells, .{ .layer = random.boolean() }); + coverFrame(cells, cols, rows, bodies, tags, &.{}); try expectCoverIsScan(cells, cols, rows, bodies, tags); } } @@ -5844,42 +5910,23 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const } } -const GripCell = struct { cell: *const pardes.Cell, offset: u16 }; - -fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { +/// 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; - if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } - if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, 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 paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { - const at = @as(usize, row) * cols + col; - if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; - for (layers) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - if (col < x or col - x >= config.GUTTER) continue; - const index = @as(usize, row) * cols + x; - if (index >= cells.len) return null; - const cell = &cells[index]; - if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null; - return .{ .cell = cell, .offset = col - x }; - } - return null; -} - fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } fn emitSurfaceCell( g: *Gui, - core: ?*const pardes.Pardes, + chrome: *const pardes.Chrome, instances: [*]CellInstance, next: *u32, col: u16, @@ -5889,18 +5936,20 @@ fn emitSurfaceCell( win_h: f32, track: ?pardes.animation.Track, line: []const pardes.Cell, + line_cover: []const Cover, at: usize, cursor: ?usize, - tagline_base: *const pardes.Cell, - grip: ?GripCell, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = cellFontRole(&line[at]); if (role == .tagline) { - var band_base = tagline_base.*; - if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; + 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. @@ -5908,27 +5957,32 @@ fn emitSurfaceCell( next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. 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 !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 (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; - if (grip) |indicator| { + const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + 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(indicator.cell)); - const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true }); + 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 offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; - const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page); + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; - instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w; + 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 (inset > 0 and (grip == null or grip.?.offset == 0)) { + if (inset > 0 and (!grip or offset == 0)) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -5940,8 +5994,7 @@ fn emitSurfaceCell( } 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(); + const chrome: pardes.Chrome = .{}; var g: Gui = undefined; g.words = .{}; g.cell_w = 10; @@ -5954,7 +6007,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. - emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + 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); @@ -5967,12 +6020,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" { // 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, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + 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, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default)); + 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); } @@ -6086,24 +6139,25 @@ test "insert cursor overlays without replacing the character beneath it" { }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); - var vertices: [6]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ - .vertices = &vertices, - .win_w = 100, - .win_h = 100, - }; - addCursorBar(&builder, 2, 3, true, 8, .{ - .x_off = 0, - .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); - for (vertices) |vertex| try std.testing.expectEqual(@as(u32, 1), vertex.ink); - try std.testing.expect(!builder.ink); + // 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 { @@ -6613,17 +6667,24 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); - var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; - surface.tag_layers[0] = layers[0]; - try std.testing.expect(gripCellChanged(&surface, 0, 0)); - try std.testing.expect(gripCellChanged(&surface, 1, 0)); - try std.testing.expect(!gripCellChanged(&surface, 2, 0)); - try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null); - layers[0].kind = .column; - try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null); + 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 has no glyph of a pane's. + 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); @@ -6647,7 +6708,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { - emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); + 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); } @@ -6802,10 +6863,12 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { g.vbuf_cells = capacity; } -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { +/// 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_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + 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{ @@ -6822,8 +6885,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) .{ .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 = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; @@ -7135,7 +7199,10 @@ fn latencyNote(comptime fmt: []const u8, args: anytype) void { _ = libc.write(latency_fd, line.ptr, line.len); } -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { +/// 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); @@ -7168,10 +7235,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t 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); + 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); } -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { +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; @@ -7185,8 +7254,8 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; - const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + 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; @@ -7212,116 +7281,263 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -fn addCursorBar( - builder: *OverlayBuilder, - x: u16, - y: u16, - bar: bool, - visual_height: f32, - layout: CellLayout, - rgb: [3]u8, -) void { - if (!bar) return; - const previous_ink = builder.ink; - builder.ink = true; - defer builder.ink = previous_ink; - const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; - const height = std.math.clamp(visual_height, 1.0, layout.h); - const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - builder.addRect( - x0, - y0, - x0 + @max(1.0, layout.w / 8.0), - y0 + height, - .{ - .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, - }, - ); +/// 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); +} + +/// 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(); + 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; + 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; + const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + 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 - 1, 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; + const grid = if (closing) surface.previous_cells else surface.cells; + 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; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + }; + try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + } + // 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. + if (index == groups.notices) for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice) 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 - 1 < top) continue; + const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; + try addDecor(g, gpa, x0, rule_y - 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 - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_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; + try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), 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, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + } + } } -fn buildOverlay( - g: *Gui, - core: ?*const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - sw: u32, - sh: u32, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, - transient_on: bool, - debug_on: bool, -) u32 { - if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; - 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); - // The workspace tag grows a row per line, so the border follows its foot. - const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H; - if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| { - const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2); - const rgb = topbar_pane_border_rgb; - builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ - .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, - }); +/// 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, 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 or groupOf(groups, grip.serial) != index) continue; + 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; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 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(1, @min(3, layout.w - 2)); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); + } } - if (core) |p| if (p.columnBarHeight() > 0) { - const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h; - builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); +} + +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; + } }; - const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; - if (grid_bottom < win_h and bottomTaglinePresent(surface)) { - const rgb = tagline_rgb; - builder.addRect(0, grid_bottom, win_w, win_h, .{ - .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, - }); - } - if (transient_on) { - if (core) |p| appendPaneChrome(p, surface, layout, &builder); - appendTagGrips(g, surface, layout, &builder); - 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 visual_height: f32 = if (role == .tagline) - @floatFromInt(g.tagline_height) - else - layout.h; - const cursor_layout = if (role == .tagline) - taglineLayoutForCell(g, cursor.x) - else - layout; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); - } + 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); + const spine = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; + } else return error.TestExpectedSpine; + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), spine.y1); + const rail = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + } else return error.TestExpectedRail; + try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + } + 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); +} + +/// The bar cursors: the grid's where no layer has the cell, and each layer's +/// at its own pitch. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + const chrome = &surface.chrome; + if (surface.cursor) |cursor| { + if (cursor.bar and 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)))); + try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); } } - if (transient_on) for (surface.bodyLayers()) |*layer| { + for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; + const cursor = layer.cursor orelse continue; + if (!cursor.bar or 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; - if (layer.cursor) |cursor| { - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; - 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]; - builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role))); - } - }; - if (transient_on) for (surface.tagLayers()) |*layer| { + 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]; + try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + } + for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; @@ -7331,10 +7547,25 @@ fn buildOverlay( const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; const y = layer.viewport.y + cursor.y; - const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); - }; - if (transient_on) appendTouchOverlay(g, &builder, debug_on); + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); + try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 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); + appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } @@ -7409,33 +7640,11 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit 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) { +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, - .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)); + else => chrome.tag_fg, + } else chrome.fg orelse fg_default; } fn overlayRgb(rgb: [3]u8) OverlayColor { @@ -7447,185 +7656,6 @@ fn overlayRgb(rgb: [3]u8) OverlayColor { }; } -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( - 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| { - const pane = slot orelse continue; - if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue; - if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; - const tag_y = core.tagTop(pane, r); - const body_y = core.bodyTop(pane, r); - const body_end = body_y + (r.h -| pane.tag_rows); - const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; - const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; - 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 + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h; - // Like the band above it, the rule of a pane at the right edge - // runs on through the pixels past the last whole cell. - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); - } - - // 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 body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; - const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1; - 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 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h; - appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); - } - } - // 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 tagline is: the same colour and - // thickness along its bottom, running to the window edge when it does. - for (surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != .notice) 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 (builder.win_w - x1 < layout.w) @max(x1, builder.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 - 1 < top) continue; - // ...and fades up and dissolves with it rather than standing alone. - var rule = overlayRgb(chrome.border); - rule.a = layer.fade; - builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule); - } - appendColumnSpines(core, surface.rows, layout, builder); -} - -// Tag handles use the canonical physical cells, including detached frames. -// Their tag text begins at the layer viewport and may use a narrower font. -fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { - if (g.tagline_height < 6 or g.tagline_width < 3) return; - for (surface.tagLayers()) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - // A mark on each of the tag's rows whose grip cell is blank: every - // row but the one with the mode's glyph. - for (0..layer.rows) |index| { - const y = layer.viewport.y + @as(u16, @intCast(index)); - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); - } - } -} - -test "column grips stay unmarked while pane marks retain physical centering" { - var g: Gui = undefined; - g.tagline_height = 12; - g.tagline_width = 6; - g.cell_h = 20; - var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); - var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - var vertices: [24]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; - appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - // Centered in the anchor, whose left edge is inset by the band's offset - // (8 px on this last row): the mark moves right by half of it. - try std.testing.expectApproxEqAbs(@as(f32, 61.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001); -} - -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(core.topBarHeight())) * layout.h; - const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; - // Down through the pixels past the last whole row, as tags run right. - const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; - 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/pardes.zig b/src/pardes.zig index f8087bdb..616747fa 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; pub const Region = @import("surface.zig").Region; +pub const Chrome = @import("surface.zig").Chrome; pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; diff --git a/src/surface.zig b/src/surface.zig index db7dd468..07b593b1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -344,6 +344,13 @@ pub const Region = struct { column_grip, column_tag, workspace_tag, + /// A column below the workspace tag, its bar and panes: its left + /// edge is the spine a pixel shell rules down it. + column, + /// A drag's guide or a seam's hover hint, drawn over the panes. + guide, + /// The debug stats box, drawn over the panes. + debug, }; pub const none = std.math.maxInt(u8); @@ -368,7 +375,30 @@ pub const Region = struct { clip: u16 = 0, }; -pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; +/// Guides a frame can draw: a run of a column-move rail per pane it skips +/// the tags of, and a handful of border and hover dashes. +pub const MAX_GUIDES = MAX_PANES + 4; +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1; + +/// The colours a pixel shell draws its chrome in (rules, rails, grips, the +/// tag underlay), resolved by the core each frame, so a frontend attached +/// to a detached session draws them from the frame alone. +pub const Chrome = struct { + /// The theme's ground and ink; null where it leaves them to the shell. + page: ?[3]u8 = null, + fg: ?[3]u8 = null, + /// Rules and column spines. + border: [3]u8 = .{ 0, 0, 0 }, + /// The rule under the workspace tag. + topbar_rule: [3]u8 = .{ 0, 0, 0 }, + tag_bg: [3]u8 = .{ 0, 0, 0 }, + /// The focused pane's and column's tag underlay: `tag_bg` unless + /// FocusTint is on. + tag_focus_bg: [3]u8 = .{ 0, 0, 0 }, + tag_fg: [3]u8 = .{ 0, 0, 0 }, + scroll_track: [3]u8 = .{ 0, 0, 0 }, + scroll_thumb: [3]u8 = .{ 0, 0, 0 }, +}; /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. @@ -385,6 +415,7 @@ pub const Surface = struct { rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, nregions: u16 = 0, + chrome: Chrome = .{}, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -395,6 +426,7 @@ pub const Surface = struct { previous_cells: []const Cell = &.{}, previous_body_layers: []const Layer = &.{}, previous_tag_layers: []const Layer = &.{}, + previous_regions: []const Region = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const Layer { @@ -409,6 +441,12 @@ pub const Surface = struct { return s.regions[0..s.nregions]; } + pub fn addRegion(s: *Surface, r: Region) void { + std.debug.assert(s.nregions < s.regions.len); + s.regions[s.nregions] = r; + s.nregions += 1; + } + /// The first part of `kind` that `owner` has: its tag, its rail. pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index e52155a3..27c39150 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -10,8 +10,9 @@ 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea -14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 -15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 -16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 -17-terminal 304a84d1ef871f581d0a3fade9bc1acdcc87a3612c607548ab0d2851871753ee +13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c +14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a +15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a +17-terminal a1305fd755b6bc957228bbf28833f972c2355af9abf40b77f76b30741109574f +18-mid-transition d4f353ef5fda80411cd9de9729e5b23f59ff7511c10774e69214152ee8070725 diff --git a/test/gui_golden.py b/test/gui_golden.py index 327911cc..d97009c9 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -106,7 +106,9 @@ def run(args): display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) if not display.is_absolute(): display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display - inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', + # The virtual clock: a transition's frames are the same frames on any + # machine, so one partway through can be hashed too. + inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), @@ -117,11 +119,12 @@ def run(args): first = require(wait_for(lambda: next((s for s, tag in panes(client).items() if 'shapes.zig' in tag), None)), 'source pane missing') - def scene(label): - results.append((label, settled(root))) + def scene(label, frame='latest.ppm'): + settled(root) + results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm')) + shutil.copyfile(root / frame, args.keep / (label + '.ppm')) print(label, results[-1][1][:16], flush=True) def mouse(kind, button, x, y): @@ -196,6 +199,13 @@ def run(args): client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') scene('17-terminal') + # Partway through a transition: the panes, the picture among them, + # sliding over for a new column, frame 6 of PanelSlide's 12. Its + # chrome slides with each pane. + client.write('/ctl', b'PanelSlide\n') + client.write(f'/pane/{first}/exec', b'Newcol\n') + require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') + scene('18-mid-transition', 'transition-6.ppm') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: GOLDENS.write_text(lines) -- cgit v1.3