diff --git a/build.zig.zon b/build.zig.zon index 4b24ccd..00aab11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,17 +4,17 @@ .version = "0.1.0", .minimum_zig_version = "0.16.0", .dependencies = .{ - .prism = .{ - .url = "git+https://github.com/Midstall/prism#d50303f2302c161ff3e9fadbb24f990248fe1e0f", - .hash = "prism-0.1.0-PSE12N28OACyQoAXy3yrAXyMxthCSa6ur1TgjVpCL0c_", - }, .webidl = .{ .url = "git+https://github.com/Midstall/webidl.zig#4b0d3c61d5f22e6072e9eacf03174e4e8393c6a2", .hash = "webidl_zig-0.1.0-FrIyV_uUBgAGtNwP-OkLegPJMWUnpeKuPqDdVbWyC9Fn", }, .lattice = .{ - .url = "git+https://github.com/Midstall/lattice#9f0a9b128dcf012e3760f440e4f5cd9562ec8752", - .hash = "lattice-0.1.0-TdtxHEviCQCoi6o8R_6d05KlMo2iVFGryVFpGbDEW81-", + .url = "git+https://github.com/Midstall/lattice#17053e425b5e472f3a6a57a382fad18b0b4ea356", + .hash = "lattice-0.1.0-TdtxHEviCQCHdTwR8eVLuDYwIIKcxFQLjQs18onvIC1q", + }, + .prism = .{ + .url = "git+https://github.com/Midstall/prism#f24fce92e8306e581c1e6fa6fe36c924183bab6a", + .hash = "prism-0.1.0-PSE12AbIOAAMnhpTrIYz4xOe1wphrH0hdEBig6v50waz", }, }, .paths = .{ "build", "build.zig", "build.zig.zon", "lib" }, diff --git a/flake.nix b/flake.nix index fae1041..731d514 100644 --- a/flake.nix +++ b/flake.nix @@ -80,7 +80,7 @@ zigDeps = pkgs.zig.fetchDeps { inherit (finalAttrs) src pname version; - hash = "sha256-8xkDtB/nAGOqPLghu4toAtQY0zEY8pzxWiwnbz99tfM="; + hash = "sha256-yj/4cpEwPMhbKvgnichxCyAILE/FE9wnb0VEuK1x/og="; }; nativeBuildInputs = with pkgs; [ diff --git a/lib/phantom/display_list.zig b/lib/phantom/display_list.zig index 7f9ebaa..09ecd90 100644 --- a/lib/phantom/display_list.zig +++ b/lib/phantom/display_list.zig @@ -100,6 +100,171 @@ pub const Primitive = union(enum) { icon: IconPrimitive, }; +/// True when two primitives would draw the same thing. +/// +/// Exhaustive on the tag, so a new primitive kind fails to compile here rather +/// than being silently treated as unchanged, which would show up as a frame that +/// never repaints. +/// +/// Every slice is compared by CONTENT, because the storage behind it is reused +/// from frame to frame: `TextRun.glyphs` and `TextRun.text` borrow RenderText's +/// buffers and `IconPrimitive.label` borrows the caller's, so comparing pointers +/// would call a changed label unchanged. The two type-erased pointers, `font` +/// and `image`, are compared by identity instead: both refer to objects that +/// outlive the frame and are not rewritten in place. +pub fn primitiveEql(a: Primitive, b: Primitive) bool { + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return switch (a) { + .rrect => |x| std.meta.eql(x, b.rrect), + .text => |x| t: { + const y = b.text; + break :t x.font == y.font and + x.size == y.size and + std.meta.eql(x.color, y.color) and + std.meta.eql(x.origin, y.origin) and + x.ascent == y.ascent and + std.mem.eql(u8, x.text, y.text) and + glyphsEql(x.glyphs, y.glyphs); + }, + .push_scroll => |x| std.meta.eql(x, b.push_scroll), + .pop_scroll => true, + .push_clip => |x| std.meta.eql(x, b.push_clip), + .pop_clip => true, + .image => |x| std.meta.eql(x, b.image), + .icon => |x| i: { + const y = b.icon; + break :i x.id == y.id and + x.size == y.size and + std.meta.eql(x.color, y.color) and + std.meta.eql(x.origin, y.origin) and + optionalBytesEql(x.label, y.label); + }, + }; +} + +/// Compared field by field rather than as raw bytes: `cp` is a `u21`, so the +/// bytes behind a `PositionedGlyph` carry padding bits that mean nothing and +/// need not match. Comparing those would report a difference on every frame, +/// which is exactly the repaint this comparison exists to avoid. +fn glyphsEql(a: []const PositionedGlyph, b: []const PositionedGlyph) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (x.cp != y.cp or x.x != y.x or x.y != y.y) return false; + } + return true; +} + +fn optionalBytesEql(a: ?[]const u8, b: ?[]const u8) bool { + if (a == null or b == null) return a == null and b == null; + return std.mem.eql(u8, a.?, b.?); +} + +/// A copy of one frame's display list, kept so the next frame can be compared +/// against it. +/// +/// This exists for backends whose cost of DRAWING a frame is far above the cost +/// of deciding whether to. The terminal's pixel mode is the case that forced it: +/// reading one 3002x1665 frame back off the GPU measured 1.05 SECONDS, and the +/// loop was paying that ten times a second to redraw a screen that had not +/// changed. Comparing the display list first costs microseconds, because a +/// frame is a few hundred primitives and a few hundred glyphs. +/// +/// The copy is what makes the comparison honest. Holding the previous frame's +/// primitives without copying their slice contents would compare this frame +/// against storage that this frame has already overwritten, which reads as "no +/// change" exactly when the text changed. +pub const Snapshot = struct { + prims: std.ArrayList(Primitive) = .empty, + /// Backing storage the captured primitives' slices are rebased onto. Two + /// arrays and not one byte blob, so `PositionedGlyph` keeps its natural + /// alignment instead of needing a cast out of a `[]u8`. + glyphs: std.ArrayList(PositionedGlyph) = .empty, + bytes: std.ArrayList(u8) = .empty, + captured: bool = false, + + pub fn deinit(self: *Snapshot, gpa: std.mem.Allocator) void { + self.prims.deinit(gpa); + self.glyphs.deinit(gpa); + self.bytes.deinit(gpa); + self.* = undefined; + } + + /// Forget the captured frame, so the next comparison reports a difference. + /// For when something other than this list changed what is on screen. + pub fn reset(self: *Snapshot) void { + self.captured = false; + } + + /// Whether `list` differs from the captured frame, capturing it when it + /// does. Nothing captured yet counts as a difference, so the first frame of + /// a run always draws. + /// + /// A matching list is NOT re-captured: the stored copy already equals it, so + /// the steady state does no copying at all. + pub fn differs(self: *Snapshot, gpa: std.mem.Allocator, list: DisplayList) !bool { + if (self.captured and self.matches(list)) return false; + try self.capture(gpa, list); + return true; + } + + fn matches(self: *const Snapshot, list: DisplayList) bool { + if (self.prims.items.len != list.primitives.items.len) return false; + for (self.prims.items, list.primitives.items) |a, b| { + if (!primitiveEql(a, b)) return false; + } + return true; + } + + fn capture(self: *Snapshot, gpa: std.mem.Allocator, list: DisplayList) !void { + // Reserved up front and filled after, so appending cannot reallocate + // part way and leave the slices rebased onto it dangling. + var total_glyphs: usize = 0; + var total_bytes: usize = 0; + for (list.primitives.items) |p| switch (p) { + .text => |t| { + total_glyphs += t.glyphs.len; + total_bytes += t.text.len; + }, + .icon => |i| total_bytes += if (i.label) |l| l.len else 0, + else => {}, + }; + try self.prims.ensureTotalCapacity(gpa, list.primitives.items.len); + try self.glyphs.ensureTotalCapacity(gpa, total_glyphs); + try self.bytes.ensureTotalCapacity(gpa, total_bytes); + self.prims.clearRetainingCapacity(); + self.glyphs.clearRetainingCapacity(); + self.bytes.clearRetainingCapacity(); + + for (list.primitives.items) |p| { + var copy = p; + switch (copy) { + .text => |*t| { + t.glyphs = self.appendGlyphs(t.glyphs); + t.text = self.appendBytes(t.text); + }, + .icon => |*i| { + if (i.label) |l| i.label = self.appendBytes(l); + }, + else => {}, + } + self.prims.appendAssumeCapacity(copy); + } + self.captured = true; + } + + fn appendGlyphs(self: *Snapshot, src: []const PositionedGlyph) []const PositionedGlyph { + const start = self.glyphs.items.len; + self.glyphs.appendSliceAssumeCapacity(src); + return self.glyphs.items[start..][0..src.len]; + } + + fn appendBytes(self: *Snapshot, src: []const u8) []const u8 { + const start = self.bytes.items.len; + self.bytes.appendSliceAssumeCapacity(src); + return self.bytes.items[start..][0..src.len]; + } +}; + pub const DisplayList = struct { primitives: std.ArrayList(Primitive) = .empty, @@ -113,3 +278,150 @@ pub const DisplayList = struct { try self.primitives.append(gpa, p); } }; + +test "a snapshot reports the first frame as a difference, then an identical one as none" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .rrect = .{ + .rect = .{ .x = 1, .y = 2, .width = 3, .height = 4 }, + .radius = 0, + .color = .{ .r = 1, .g = 0, .b = 0 }, + } }); + + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); +} + +test "a changed primitive is a difference, and the frame after it is not" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .rrect = .{ + .rect = .{ .x = 1, .y = 2, .width = 3, .height = 4 }, + .radius = 0, + .color = .{ .r = 1, .g = 0, .b = 0 }, + } }); + try std.testing.expect(try snap.differs(gpa, list)); + + // The colour a Button changes on hover, which is a repaint with no rebuild. + list.primitives.items[0].rrect.color = .{ .r = 0, .g = 1, .b = 0 }; + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); +} + +test "text is compared by content, so reusing the buffer behind it cannot hide a change" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + // ONE buffer, rewritten in place between frames. This is what RenderText + // does: the slice in the display list borrows storage the next layout + // overwrites. A snapshot that kept the slice instead of copying what it + // pointed at would be comparing this frame against itself and would report + // "no change" for every edit a text field ever makes. + var buf: [8]u8 = undefined; + @memcpy(buf[0..5], "Taps0"); + var font: u8 = 0; + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .text = .{ + .glyphs = &.{}, + .text = buf[0..5], + .font = @ptrCast(&font), + .size = 16, + .color = .{ .r = 1, .g = 1, .b = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); + + @memcpy(buf[0..5], "Taps1"); + try std.testing.expect(try snap.differs(gpa, list)); +} + +test "glyph positions are compared, so text that moved without changing is a difference" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + var glyphs = [_]PositionedGlyph{ .{ .cp = 'a', .x = 0, .y = 0 }, .{ .cp = 'b', .x = 8, .y = 0 } }; + var font: u8 = 0; + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .text = .{ + .glyphs = &glyphs, + .text = "ab", + .font = @ptrCast(&font), + .size = 16, + .color = .{ .r = 1, .g = 1, .b = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); + + // Same characters, laid out one pixel further along. + glyphs[1].x = 9; + try std.testing.expect(try snap.differs(gpa, list)); +} + +test "a primitive appended or removed is a difference even when every shared one matches" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + const box = Primitive{ .rrect = .{ + .rect = .{ .x = 0, .y = 0, .width = 10, .height = 10 }, + .radius = 0, + .color = .{ .r = 1, .g = 1, .b = 1 }, + } }; + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, box); + try std.testing.expect(try snap.differs(gpa, list)); + + try list.append(gpa, box); + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); + + _ = list.primitives.pop(); + try std.testing.expect(try snap.differs(gpa, list)); +} + +test "reset forces the next frame to draw, for when something else wrote to the screen" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .pop_clip = {} }); + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); + + snap.reset(); + try std.testing.expect(try snap.differs(gpa, list)); +} + +test "an empty frame after a drawn one is a difference, and stays quiet after that" { + const gpa = std.testing.allocator; + var snap: Snapshot = .{}; + defer snap.deinit(gpa); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .pop_scroll = {} }); + try std.testing.expect(try snap.differs(gpa, list)); + + list.clear(); + try std.testing.expect(try snap.differs(gpa, list)); + try std.testing.expect(!try snap.differs(gpa, list)); +} diff --git a/lib/phantom/tui.zig b/lib/phantom/tui.zig index c168648..e79892e 100644 --- a/lib/phantom/tui.zig +++ b/lib/phantom/tui.zig @@ -112,21 +112,45 @@ fn textMetricsFor(m: Mode, cell_w: f32, cell_h: f32) phantom.text.mono.TextMetri }; } -/// The smallest rectangle that contains both `a` and `b`. +/// How many damage images may stack over one base before the session sends a +/// fresh full-screen one and frees them all. /// -/// Mode A deletes the previous frame's image by id once the new one is placed, -/// which frees exactly that image's own footprint and nothing else. If the new -/// frame only transmitted its own small damage rectangle, the region the old -/// image used to cover but the new one does not touch would go blank the moment -/// the old id is freed. Growing the new frame's rectangle to also cover the -/// previous one's footprint means the new image fully replaces it, so freeing the -/// old id never uncovers anything. See `Session.renderPixels`. -fn unionRect(a: tui_pixels.Rect, b: tui_pixels.Rect) tui_pixels.Rect { - const x0 = @min(a.x, b.x); - const y0 = @min(a.y, b.y); - const x1 = @max(a.x + a.w, b.x + b.w); - const y1 = @max(a.y + a.h, b.y + b.h); - return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; +/// Two bounds guard two different things and both are needed. This one caps how +/// many placements the terminal has to composite for every cell it draws. The +/// area rule beside it caps the WORK: once the overlays add up to a screenful, +/// a full frame costs no more than what has already been spent, so the amortised +/// cost never exceeds twice that of always sending full frames, and in the +/// ordinary case of a small thing changing it is a tiny fraction of it. +const max_overlays = 32; + +/// What the terminal already holds for mode A, which is what decides whether the +/// next frame is a base or an overlay. +const FrameState = struct { + /// Whether a full-screen image is on the terminal at all. + has_base: bool, + /// How many damage images are stacked on it. + overlays: usize, + /// How many pixels those add up to, counted with overlap. + overlay_pixels: u64, + /// Set when the session knows the terminal no longer shows what it thinks. + forced: bool, +}; + +/// Whether the next frame must be a full-screen base rather than an overlay. +/// +/// Split out from `renderPixels` and made pure because it is a POLICY, and the +/// policy is where this went wrong before: the old code grew the transmitted +/// rectangle to cover the previous one and then stored the grown rectangle, so +/// it could only ever grow. One full-screen frame, which the first frame always +/// is, made every later frame full-screen too. A rule that can only ratchet one +/// way is not visible by reading the frame path, and it is very visible in a +/// test that feeds it the same small damage twice. +fn needsBase(state: FrameState, damage_pixels: u64, screen_pixels: u64) bool { + if (state.forced or !state.has_base) return true; + if (state.overlays >= max_overlays) return true; + // `>=` and not `>`: at exactly one screenful the two cost the same, and the + // base is worth more, because it also frees every overlay behind it. + return state.overlay_pixels + damage_pixels >= screen_pixels; } /// Where a damage rectangle lands once it is placed on the cell grid: the crop @@ -449,8 +473,26 @@ pub const Session = struct { /// frame can free it by id once it has been fully replaced. Null before the /// first pixels frame, and reset on every resize, since the footprint's /// coordinates stop meaning anything once the surface changes size. - prev_id: ?u32, - prev_footprint: ?tui_pixels.Rect, + /// The id of the full-screen image everything else is drawn on top of, or + /// null before the first one is sent. + base_id: ?u32, + /// The ids of the damage images placed over the base since it was sent, in + /// the order they were placed. Freed together at the next rebase. + overlays: std.ArrayList(u32), + /// How many pixels those overlays cover in total, counted with overlap: it + /// is a measure of work spent, not of area covered. Once it reaches a whole + /// screen, a fresh full-screen image costs no more than what has already + /// been sent, so it is time to send one. + overlay_pixels: u64, + /// Send a full-screen image on the next frame whatever the damage says. + /// Set when the geometry changed under the session, or when something other + /// than the session wrote to the screen. + rebase: bool, + /// The display list this session last drew. Mode A compares against it + /// before touching the GPU, because reading one frame back costs about a + /// second at a real terminal size and an idle loop was paying that ten times + /// a second for a screen nobody had changed. + snapshot: phantom.display_list.Snapshot, frame: std.ArrayList(u8), decoder: decode.Decoder, @@ -636,8 +678,11 @@ pub const Session = struct { null; errdefer if (self.surface) |*s| s.deinit(); self.image_id = 1; - self.prev_id = null; - self.prev_footprint = null; + self.base_id = null; + self.overlays = .empty; + self.overlay_pixels = 0; + self.rebase = false; + self.snapshot = .{}; self.frame = .empty; self.decoder = .{ .pixels = self.caps.sgr_pixel_mouse and self.mode == .pixels }; @@ -660,6 +705,8 @@ pub const Session = struct { /// removed, so a signal arriving during teardown still finds a live restore. pub fn deinit(self: *Session) void { self.frame.deinit(self.gpa); + self.overlays.deinit(self.gpa); + self.snapshot.deinit(self.gpa); if (self.surface) |*s| s.deinit(); self.grid.deinit(); self.canvas.deinit(); @@ -742,14 +789,18 @@ pub const Session = struct { @intFromFloat(self.viewport.width), @intFromFloat(self.viewport.height), ); - // 4c. The previous frame's footprint was measured in the old surface's - // coordinates. A shrink can put it outside the new surface entirely, - // so it cannot seed the next frame's union rectangle: the next - // damaged frame falls back to just its own damage, which - // `PixelSurface.resize` already forces to cover the whole surface. - // `prev_id` is untouched: freeing an id does not depend on geometry, - // so it is still deleted normally once the next frame supersedes it. - self.prev_footprint = null; + // 4c. Every image on the terminal describes the OLD geometry, so the + // next frame has to be a fresh full-screen base. That also frees + // them: the rebase deletes the old base and every overlay once the + // new one is placed, which is the only thing that reclaims images + // sized for a window that no longer exists. + self.rebase = true; + // 4d. The screen was cleared and the surface resized, so the next frame + // must be drawn whatever the display list says. A relayout usually + // changes the list anyway, but a grid that resized without moving + // anything would otherwise compare equal and draw nothing onto a + // screen that no longer holds it. + self.snapshot.reset(); // 5. MediaQuery.of reports the logical size, so widgets see stable // numbers even though the physical one just changed underneath. self.owner.setActiveViewMetrics(.{ @@ -954,6 +1005,18 @@ pub const Session = struct { } fn renderPixels(self: *Session) !void { + // Asked BEFORE the GPU is touched, and this is the whole reason the + // snapshot exists. `PixelSurface.damage` answers the same question more + // precisely, but only after `renderFrame` has read the pixels back, and + // that readback measured 1.05 SECONDS for one 3002x1665 frame: the loop + // was spending a second of every tenth of a second to find out that a + // still screen was still. Comparing the display list costs microseconds + // and answers it before any of that. + // + // Conservative in the safe direction: a list that differs still goes + // through `damage`, so a change that happens to produce identical pixels + // costs one readback and still transmits nothing. + if (!try self.snapshot.differs(self.gpa, self.canvas.list)) return; const s = &self.surface.?; const pixels = try s.renderFrame(self.canvas.list, phantom.ColorScheme.tokyoNight().bg); // `damage` returns null on an unchanged frame, and nothing below runs: @@ -961,29 +1024,68 @@ pub const Session = struct { // nothing at all, or every idle tick would retransmit the whole image // and saturate the pty. const r = s.damage(pixels) orelse return; - // Grown to also cover whatever the visible image occupies, not just this - // frame's own damage. The new image is about to fully replace the old - // one (the old id is freed below), so its footprint must be a superset - // of the old one's, or the region the new frame does not touch would go - // blank the moment the old id's data is freed. - const want = if (self.prev_footprint) |pf| unionRect(pf, r) else r; - const a = alignDamageToCells(want, self.cell_w, self.cell_h); - const crop = try s.cropRect(self.gpa, pixels, a.crop); + const a = alignDamageToCells(r, self.cell_w, self.cell_h); + + // A frame is either a fresh BASE, which covers the screen and retires + // everything sent before it, or an OVERLAY, which covers only what + // changed and is composited on top of the base by the terminal. + // + // Sending a base every frame is what this code used to do, and it was + // not a choice: the old id was deleted as soon as the new one was + // placed, deleting an image takes its placement off the screen with it, + // so each new image had to cover everything the last one showed. The + // first frame covers the screen by definition, so every frame after it + // did too, for ever. That cost 1990 ms per frame at 3002x1665, almost + // all of it compressing 19 MB, against 47 ms for a 600x200 damage + // rectangle. Not deleting the old image is what breaks that chain. + const screen_pixels: u64 = @as(u64, @intFromFloat(self.viewport.width)) * + @as(u64, @intFromFloat(self.viewport.height)); + const this_pixels: u64 = @as(u64, a.crop.w) * a.crop.h; + const send_base = needsBase(.{ + .has_base = self.base_id != null, + .overlays = self.overlays.items.len, + .overlay_pixels = self.overlay_pixels, + .forced = self.rebase, + }, this_pixels, screen_pixels); + + const rect: tui_pixels.Rect = if (send_base) + .{ .x = 0, .y = 0, .w = @intFromFloat(self.viewport.width), .h = @intFromFloat(self.viewport.height) } + else + a.crop; + const place: kitty_gfx.Placement = if (send_base) + .{ .col = 0, .row = 0, .z = 0 } + else + .{ .col = a.place.col, .row = a.place.row, .z = @intCast(self.overlays.items.len + 1) }; + + // Reserved BEFORE anything is written, so a failure to record the id + // cannot leave an image on the terminal that nothing will ever free. + if (!send_base) try self.overlays.ensureUnusedCapacity(self.gpa, 1); + + const crop = try s.cropRect(self.gpa, pixels, rect); defer self.gpa.free(crop); if (self.caps.sync_output) try self.frame.appendSlice(self.gpa, ansi.sync_begin); try kitty_gfx.transmit(self.gpa, &self.frame, .{ .id = self.image_id, - .width = a.crop.w, - .height = a.crop.h, + .width = rect.w, + .height = rect.h, .rgba = crop, - }, a.place); - // The old id is freed only after the new image is placed, so there is - // never a moment with nothing on screen: the new placement already - // covers everything the old one did. - if (self.prev_id) |pid| try kitty_gfx.deleteImage(self.gpa, &self.frame, pid); + }, place); + + if (send_base) { + // Freed only after the new base is placed, so there is never a + // moment with nothing on screen: the base already covers everything + // any of them showed. + if (self.base_id) |id| try kitty_gfx.deleteImage(self.gpa, &self.frame, id); + for (self.overlays.items) |id| try kitty_gfx.deleteImage(self.gpa, &self.frame, id); + self.overlays.clearRetainingCapacity(); + self.overlay_pixels = 0; + self.base_id = self.image_id; + self.rebase = false; + } else { + self.overlays.appendAssumeCapacity(self.image_id); + self.overlay_pixels += this_pixels; + } if (self.caps.sync_output) try self.frame.appendSlice(self.gpa, ansi.sync_end); - self.prev_footprint = a.crop; - self.prev_id = self.image_id; self.image_id = if (self.image_id >= 1_000_000) 1 else self.image_id + 1; } @@ -1020,10 +1122,13 @@ pub const Session = struct { /// nothing at all. See `cell_grid.Positioning.relative`. pub fn invalidate(self: *Session) void { self.grid.invalidate(); - // Mode A's damage tracking is the pixel equivalent of the front buffer, - // and the same reasoning applies: drop the previous footprint so the - // next frame cannot be diffed against a placement that has moved. - self.prev_footprint = null; + // The list did not change, but what is on screen did, so the next frame + // has to be drawn even though comparing would say otherwise. + self.snapshot.reset(); + // Mode A composites a base and its overlays, and something else has + // just written over them, so no overlay can be trusted to still be + // showing what it was. Only a full-screen base puts that right. + self.rebase = true; if (self.surface) |*s| s.invalidate(); } @@ -1185,31 +1290,6 @@ test "textMetricsFor gives pixels mode the proportional metrics and ignores the try std.testing.expectEqual(phantom.text.mono.TextMetrics.proportional, tm); } -test "unionRect covers both rectangles when neither contains the other" { - const r = unionRect( - .{ .x = 10, .y = 10, .w = 5, .h = 5 }, // covers 10..14 - .{ .x = 20, .y = 30, .w = 5, .h = 5 }, // covers 20..24, 30..34 - ); - try std.testing.expectEqual(tui_pixels.Rect{ .x = 10, .y = 10, .w = 15, .h = 25 }, r); -} - -test "unionRect of a rectangle with itself is unchanged" { - const a = tui_pixels.Rect{ .x = 4, .y = 9, .w = 6, .h = 2 }; - try std.testing.expectEqual(a, unionRect(a, a)); -} - -test "unionRect is not affected by argument order" { - const a = tui_pixels.Rect{ .x = 12, .y = 1, .w = 3, .h = 40 }; - const b = tui_pixels.Rect{ .x = 0, .y = 5, .w = 50, .h = 1 }; - try std.testing.expectEqual(unionRect(a, b), unionRect(b, a)); -} - -test "unionRect swallows a rectangle fully contained in the other" { - const outer = tui_pixels.Rect{ .x = 0, .y = 0, .w = 100, .h = 100 }; - const inner = tui_pixels.Rect{ .x = 10, .y = 10, .w = 5, .h = 5 }; - try std.testing.expectEqual(outer, unionRect(outer, inner)); -} - test "alignDamageToCells leaves a rectangle already on a cell boundary unchanged" { const a = alignDamageToCells(.{ .x = 10, .y = 20, .w = 5, .h = 5 }, 10, 10); try std.testing.expectEqual(tui_pixels.Rect{ .x = 10, .y = 20, .w = 5, .h = 5 }, a.crop); @@ -1844,3 +1924,100 @@ test "a session leaves stderr alone under the leave policy" { try std.testing.expect(h.session.term.saved_stderr == null); try std.testing.expect(h.session.term.stderr_target == null); } + +/// A screen big enough that the thresholds below are the only thing deciding. +const test_screen: u64 = 3002 * 1665; + +test "the first frame of a run must be a full-screen base, because nothing is on the terminal yet" { + try std.testing.expect(needsBase(.{ + .has_base = false, + .overlays = 0, + .overlay_pixels = 0, + .forced = false, + }, 100, test_screen)); +} + +test "small damage over an existing base stays an overlay, and does not ratchet" { + // The regression this whole scheme exists for. The old code grew the + // transmitted rectangle to cover the previous one and stored the result, so + // the second frame after a full-screen first frame was full-screen, and so + // was every frame after that for ever. Here the same small damage is offered + // repeatedly and has to stay small every time. + var state = FrameState{ .has_base = true, .overlays = 0, .overlay_pixels = 0, .forced = false }; + const damage: u64 = 600 * 200; + var i: usize = 0; + while (i < 20) : (i += 1) { + try std.testing.expect(!needsBase(state, damage, test_screen)); + state.overlays += 1; + state.overlay_pixels += damage; + } +} + +test "a forced rebase wins over every other consideration" { + try std.testing.expect(needsBase(.{ + .has_base = true, + .overlays = 0, + .overlay_pixels = 0, + .forced = true, + }, 1, test_screen)); +} + +test "enough overlays trigger a base even while their area stays small" { + // A pointer moving one cell at a time produces tiny damage for ever. Without + // the count bound the terminal would be asked to composite an unbounded + // stack of placements for every cell it draws. + const tiny: u64 = 16; + try std.testing.expect(!needsBase(.{ + .has_base = true, + .overlays = max_overlays - 1, + .overlay_pixels = tiny * (max_overlays - 1), + .forced = false, + }, tiny, test_screen)); + try std.testing.expect(needsBase(.{ + .has_base = true, + .overlays = max_overlays, + .overlay_pixels = tiny * max_overlays, + .forced = false, + }, tiny, test_screen)); +} + +test "overlays adding up to a screenful trigger a base even while their count stays low" { + // Two big damage rectangles can reach a screenful long before the count + // bound does, and at that point a full frame costs no more than what has + // already been sent. + const half = test_screen / 2; + try std.testing.expect(!needsBase(.{ + .has_base = true, + .overlays = 1, + .overlay_pixels = half, + .forced = false, + }, half - 1, test_screen)); + try std.testing.expect(needsBase(.{ + .has_base = true, + .overlays = 1, + .overlay_pixels = half, + .forced = false, + }, half, test_screen)); +} + +test "the amortised cost never exceeds twice that of sending a full frame every time" { + // Walk the rule the way the loop does and add up what it would send. The + // bound is what justifies overlays at all: they are only worth doing if the + // occasional full frame cannot make the total worse than the scheme they + // replaced. + var state = FrameState{ .has_base = true, .overlays = 0, .overlay_pixels = 0, .forced = false }; + const damage: u64 = test_screen / 10; + var sent: u64 = 0; + var frames: u64 = 0; + while (frames < 200) : (frames += 1) { + if (needsBase(state, damage, test_screen)) { + sent += test_screen; + state = .{ .has_base = true, .overlays = 0, .overlay_pixels = 0, .forced = false }; + } else { + sent += damage; + state.overlays += 1; + state.overlay_pixels += damage; + } + } + try std.testing.expect(sent < 2 * test_screen * frames); +} diff --git a/lib/phantom/tui/kitty_gfx.zig b/lib/phantom/tui/kitty_gfx.zig index 045924d..8cb50bd 100644 --- a/lib/phantom/tui/kitty_gfx.zig +++ b/lib/phantom/tui/kitty_gfx.zig @@ -17,9 +17,49 @@ pub const ImageDesc = struct { height: u32, /// Straight RGBA, eight bits for each channel. rgba: []const u8, + /// Deflate the pixels before base64 and tell the terminal with `o=z`. + /// + /// A user interface frame is mostly flat colour, so this is not a marginal + /// saving. One measured 3002x1665 frame is 19 MB of RGBA, which base64 + /// expands to 25 MB on the wire; compressed first it is 20 KB, and 26 KB + /// encoded. That is the difference between a startup that writes 25 MB + /// through a pty for the terminal to parse and one that writes 26 KB. + compress: bool = true, }; -pub const Placement = struct { col: u16, row: u16 }; +/// Deflate `src` into a zlib stream, which is the container `o=z` names. The +/// caller frees the result. +/// +/// Level 1, the fastest. The ratio above is already about a thousand to one on +/// real frame data, so the slower levels would spend time to save bytes that are +/// no longer the bottleneck. +fn deflate(gpa: std.mem.Allocator, src: []const u8) ![]u8 { + // `Compress.init` asserts the output has more than 8 bytes of buffer, so the + // capacity is not merely an optimisation here. + var out = try std.Io.Writer.Allocating.initCapacity(gpa, @max(1024, src.len / 64)); + errdefer out.deinit(); + const window = try gpa.alloc(u8, std.compress.flate.max_window_len); + defer gpa.free(window); + var c = try std.compress.flate.Compress.init(&out.writer, window, .zlib, .level_1); + try c.writer.writeAll(src); + try c.finish(); + return out.toOwnedSlice(); +} + +pub const Placement = struct { + col: u16, + row: u16, + /// Vertical stacking order. A higher `z` composites above a lower one where + /// two placements overlap. + /// + /// Stated rather than left to default, because the session places small + /// damage images on top of a full-screen base and the result is only correct + /// if the newer one wins. Two placements sharing a z-index have no order the + /// protocol promises, so the base takes 0 and each overlay takes the next + /// value up. Kept at or above zero: a negative z has its own meaning in the + /// protocol, placing the image beneath the text rather than over it. + z: i32 = 0, +}; /// Send one image and place it at one cell. The cursor moves to the cell first, /// because a placement lands at the cursor, and `C=1` stops the placement moving it. @@ -34,11 +74,15 @@ pub fn transmit( var cur_buf: [16]u8 = undefined; try out.appendSlice(gpa, ansi.cursorTo(&cur_buf, place.row, place.col)); + const packed_pixels: ?[]u8 = if (img.compress) try deflate(gpa, img.rgba) else null; + defer if (packed_pixels) |p| gpa.free(p); + const payload = packed_pixels orelse img.rgba; + const encoder = std.base64.standard.Encoder; - const encoded_len = encoder.calcSize(img.rgba.len); + const encoded_len = encoder.calcSize(payload.len); const encoded = try gpa.alloc(u8, encoded_len); defer gpa.free(encoded); - _ = encoder.encode(encoded, img.rgba); + _ = encoder.encode(encoded, payload); var offset: usize = 0; var first = true; @@ -51,11 +95,22 @@ pub fn transmit( // f=32 is RGBA. a=T transmits and displays in one step. q=2 suppresses the // response, because the loop does not read one and an unread response would // arrive in the middle of the next input decode. + // o=z says the payload is a zlib stream of the RGBA, which the + // terminal inflates before reading it as pixels. `s` and `v` still + // describe the IMAGE, not the payload: the size on the wire is not + // something the protocol is told. var head: [96]u8 = undefined; const s = try std.fmt.bufPrint( &head, - "a=T,f=32,s={d},v={d},i={d},C=1,q=2,m={d}", - .{ img.width, img.height, img.id, @intFromBool(!is_last) }, + "a=T,f=32,s={d},v={d},i={d},C=1,q=2,z={d}{s},m={d}", + .{ + img.width, + img.height, + img.id, + place.z, + if (img.compress) ",o=z" else "", + @intFromBool(!is_last), + }, ); try out.appendSlice(gpa, s); first = false; @@ -113,18 +168,6 @@ test "a one pixel image transmits as one chunk with the expected control keys" { try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b\\")); } -test "the payload is the base64 of the pixel bytes" { - const gpa = std.testing.allocator; - var out: std.ArrayList(u8) = .empty; - defer out.deinit(gpa); - - const rgba = [_]u8{ 255, 0, 0, 255 }; - try transmit(gpa, &out, .{ .id = 7, .width = 1, .height = 1, .rgba = &rgba }, .{ .col = 0, .row = 0 }); - - // The four bytes FF 00 00 FF encode as /wAA/w== - try std.testing.expect(std.mem.indexOf(u8, out.items, "/wAA/w==") != null); -} - test "a payload larger than the chunk limit splits and marks every chunk but the last" { const gpa = std.testing.allocator; var out: std.ArrayList(u8) = .empty; @@ -135,7 +178,9 @@ test "a payload larger than the chunk limit splits and marks every chunk but the defer gpa.free(pixels); @memset(pixels, 0x40); - try transmit(gpa, &out, .{ .id = 2, .width = 2048, .height = 1, .rgba = pixels }, .{ .col = 0, .row = 0 }); + // Uncompressed on purpose: 8192 bytes of one repeated value deflate to + // almost nothing, and then there would be no second chunk to test. + try transmit(gpa, &out, .{ .id = 2, .width = 2048, .height = 1, .rgba = pixels, .compress = false }, .{ .col = 0, .row = 0 }); // Every chunk except the last carries m=1, and the last carries m=0. try std.testing.expect(std.mem.count(u8, out.items, "m=1") >= 2); @@ -152,7 +197,7 @@ test "no chunk body is longer than the protocol limit" { const pixels = try gpa.alloc(u8, 5000 * 4); defer gpa.free(pixels); @memset(pixels, 0x11); - try transmit(gpa, &out, .{ .id = 3, .width = 5000, .height = 1, .rgba = pixels }, .{ .col = 0, .row = 0 }); + try transmit(gpa, &out, .{ .id = 3, .width = 5000, .height = 1, .rgba = pixels, .compress = false }, .{ .col = 0, .row = 0 }); var it = std.mem.splitSequence(u8, out.items, "\x1b_G"); _ = it.next(); // the text before the first sequence @@ -198,3 +243,120 @@ test "an empty pixel buffer produces no sequence at all" { try transmit(gpa, &out, .{ .id = 1, .width = 0, .height = 0, .rgba = &.{} }, .{ .col = 0, .row = 0 }); try std.testing.expectEqual(@as(usize, 0), out.items.len); } + +/// Pull the base64 bodies out of every chunk and join them, which is what a +/// terminal does before decoding. +fn joinPayload(gpa: std.mem.Allocator, sequence: []const u8) ![]u8 { + var joined: std.ArrayList(u8) = .empty; + errdefer joined.deinit(gpa); + var it = std.mem.splitSequence(u8, sequence, ansi.apc ++ "G"); + _ = it.next(); + while (it.next()) |chunk| { + const semi = std.mem.indexOfScalar(u8, chunk, ';') orelse continue; + const end = std.mem.indexOf(u8, chunk, ansi.st) orelse chunk.len; + try joined.appendSlice(gpa, chunk[semi + 1 .. end]); + } + return joined.toOwnedSlice(gpa); +} + +test "a compressed image round trips: the payload inflates back to the exact pixels" { + const gpa = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + + // Flat runs with a block of a second colour, which is what a real frame + // looks like and what makes deflate worth doing at all. + const w = 64; + const h = 32; + const rgba = try gpa.alloc(u8, w * h * 4); + defer gpa.free(rgba); + @memset(rgba, 0x20); + for (10..20) |y| { + for (5..40) |x| { + const off = (y * w + x) * 4; + rgba[off] = 0xC0; + rgba[off + 1] = 0x40; + } + } + + try transmit(gpa, &out, .{ .id = 5, .width = w, .height = h, .rgba = rgba }, .{ .col = 0, .row = 0 }); + try std.testing.expect(std.mem.indexOf(u8, out.items, "o=z") != null); + + const encoded = try joinPayload(gpa, out.items); + defer gpa.free(encoded); + + const decoder = std.base64.standard.Decoder; + const raw_len = try decoder.calcSizeForSlice(encoded); + const raw = try gpa.alloc(u8, raw_len); + defer gpa.free(raw); + try decoder.decode(raw, encoded); + + // The terminal's side: inflate the zlib stream and compare to what went in. + var reader = std.Io.Reader.fixed(raw); + var window: [std.compress.flate.max_window_len]u8 = undefined; + var d = std.compress.flate.Decompress.init(&reader, .zlib, &window); + const inflated = try d.reader.allocRemaining(gpa, .unlimited); + defer gpa.free(inflated); + + try std.testing.expectEqualSlices(u8, rgba, inflated); +} + +test "compression is what shrinks the payload, and turning it off restores the raw base64" { + const gpa = std.testing.allocator; + const w = 64; + const h = 32; + const rgba = try gpa.alloc(u8, w * h * 4); + defer gpa.free(rgba); + @memset(rgba, 0x20); + + var small: std.ArrayList(u8) = .empty; + defer small.deinit(gpa); + try transmit(gpa, &small, .{ .id = 1, .width = w, .height = h, .rgba = rgba }, .{ .col = 0, .row = 0 }); + + var big: std.ArrayList(u8) = .empty; + defer big.deinit(gpa); + try transmit(gpa, &big, .{ + .id = 1, + .width = w, + .height = h, + .rgba = rgba, + .compress = false, + }, .{ .col = 0, .row = 0 }); + + // The uncompressed form says nothing about compression, and is far larger. + try std.testing.expect(std.mem.indexOf(u8, big.items, "o=z") == null); + try std.testing.expect(small.items.len * 4 < big.items.len); +} + +test "an uncompressed image still carries the raw base64 of its pixels" { + const gpa = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + const rgba = [_]u8{ 255, 0, 0, 255 }; + try transmit(gpa, &out, .{ + .id = 7, + .width = 1, + .height = 1, + .rgba = &rgba, + .compress = false, + }, .{ .col = 0, .row = 0 }); + try std.testing.expect(std.mem.indexOf(u8, out.items, "/wAA/w==") != null); +} + +test "a placement states its stacking order, so an overlay composites above the base" { + const gpa = std.testing.allocator; + const rgba = [_]u8{ 0, 0, 0, 255 }; + + var base: std.ArrayList(u8) = .empty; + defer base.deinit(gpa); + try transmit(gpa, &base, .{ .id = 1, .width = 1, .height = 1, .rgba = &rgba }, .{ .col = 0, .row = 0, .z = 0 }); + try std.testing.expect(std.mem.indexOf(u8, base.items, "z=0") != null); + + var overlay: std.ArrayList(u8) = .empty; + defer overlay.deinit(gpa); + try transmit(gpa, &overlay, .{ .id = 2, .width = 1, .height = 1, .rgba = &rgba }, .{ .col = 3, .row = 4, .z = 7 }); + // Stated outright rather than left to the default: two placements sharing a + // z-index have no order the protocol promises, so a damage image drawn over + // the base would be free to end up underneath it. + try std.testing.expect(std.mem.indexOf(u8, overlay.items, "z=7") != null); +}