Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; [
Expand Down
312 changes: 312 additions & 0 deletions lib/phantom/display_list.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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));
}
Loading