lib/gui/src/paint/command.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const gui = @import("../root.zig");
  4 
  5 const Allocator = std.mem.Allocator;
  6 const Color = gui.model.UiColor;
  7 const Gradient = gui.model.UiLinearGradient;
  8 pub const Image = gui.model.UiImage;
  9 pub const ImageSet = gui.model.UiImageSet;
 10 const Point = gui.model.UiPoint;
 11 const Rect = gui.layout.Rect;
 12 
 13 pub const Kind = enum(u32) {
 14     fill = 1,
 15     stroke = 2,
 16     image = 3,
 17     shadow = 4,
 18     glyph = 5,
 19     linear_gradient = 6,
 20 };
 21 
 22 pub const Options = struct {
 23     visual: gui.visual.Options = .{},
 24     scale: f32 = 1,
 25 };
 26 
 27 pub const fragment_namespace_widget: u32 = 1;
 28 pub const fragment_namespace_layer: u32 = 2;
 29 pub const fragment_part_chrome: u32 = 1;
 30 pub const fragment_part_text: u32 = 2;
 31 
 32 pub const FragmentId = struct {
 33     root_id: u64,
 34     element_id: u64,
 35     namespace: u32,
 36     part: u32,
 37 };
 38 
 39 pub const Fragment = struct {
 40     id: FragmentId,
 41     command_start: u32,
 42     command_count: u32,
 43     bounds: Rect,
 44 };
 45 
 46 pub const Command = struct {
 47     kind: Kind,
 48     rect: Rect,
 49     clip: Rect,
 50     source: Rect = .{},
 51     color: Color,
 52     color_end: Color = .{},
 53     gradient_start: Point = .{},
 54     gradient_end: Point = .{},
 55     radius: f32 = 0,
 56     width: f32 = 0,
 57     image_index: u32 = 0,
 58     order: u32 = 0,
 59 };
 60 
 61 pub const CommandBuffer = struct {
 62     pub const Error = Allocator.Error || error{
 63         InvalidFragmentStart,
 64         TooManyCommands,
 65     };
 66 
 67     allocator: Allocator,
 68     commands: std.ArrayListUnmanaged(Command) = .empty,
 69     fragments: std.ArrayListUnmanaged(Fragment) = .empty,
 70 
 71     pub fn init(allocator: Allocator) CommandBuffer {
 72         return .{ .allocator = allocator };
 73     }
 74 
 75     pub fn deinit(self: *CommandBuffer) void {
 76         self.commands.deinit(self.allocator);
 77         self.fragments.deinit(self.allocator);
 78         self.* = undefined;
 79     }
 80 
 81     pub fn reset(self: *CommandBuffer) void {
 82         self.commands.clearRetainingCapacity();
 83         self.fragments.clearRetainingCapacity();
 84     }
 85 
 86     pub fn ensureCapacity(self: *CommandBuffer, capacity: usize) Allocator.Error!void {
 87         try self.commands.ensureTotalCapacity(self.allocator, capacity);
 88     }
 89 
 90     pub fn ensureFragmentCapacity(
 91         self: *CommandBuffer,
 92         capacity: usize,
 93     ) Allocator.Error!void {
 94         try self.fragments.ensureTotalCapacity(self.allocator, capacity);
 95     }
 96 
 97     pub fn items(self: *const CommandBuffer) []const Command {
 98         return self.commands.items;
 99     }
100 
101     pub fn fragmentItems(self: *const CommandBuffer) []const Fragment {
102         return self.fragments.items;
103     }
104 
105     pub fn fragmentsComplete(self: *const CommandBuffer) bool {
106         return self.coveredCommandCount() == self.commands.items.len;
107     }
108 
109     pub fn commitFragment(
110         self: *CommandBuffer,
111         id: FragmentId,
112         command_start: usize,
113     ) Error!void {
114         if (command_start != self.coveredCommandCount()) {
115             return error.InvalidFragmentStart;
116         }
117         if (command_start > self.commands.items.len) {
118             return error.InvalidFragmentStart;
119         }
120         const command_count = self.commands.items.len - command_start;
121         if (command_count == 0) return;
122         const start = std.math.cast(u32, command_start) orelse
123             return error.TooManyCommands;
124         const count = std.math.cast(u32, command_count) orelse
125             return error.TooManyCommands;
126         try self.fragments.append(self.allocator, .{
127             .id = id,
128             .command_start = start,
129             .command_count = count,
130             .bounds = fragmentBounds(self.commands.items[command_start..]),
131         });
132     }
133 
134     pub fn appendFrame(
135         self: *CommandBuffer,
136         frame: gui.model.UiFrame,
137         root_id: u64,
138         target_width: u32,
139         target_height: u32,
140         options: Options,
141     ) Error!void {
142         const command_start = self.commands.items.len;
143         const fragment_start = self.fragments.items.len;
144         errdefer {
145             self.commands.shrinkRetainingCapacity(command_start);
146             self.fragments.shrinkRetainingCapacity(fragment_start);
147         }
148         var recorder = Recorder.init(self, .{
149             .x = 0,
150             .y = 0,
151             .width = @floatFromInt(target_width),
152             .height = @floatFromInt(target_height),
153         }, options.scale);
154         gui.visual.drawFrame(&recorder, frame, root_id, options.visual);
155         std.debug.assert(recorder.fragment_start == null);
156         if (recorder.failure) |failure| return failure;
157     }
158 
159     pub fn append(self: *CommandBuffer, raw: Command) Allocator.Error!void {
160         if (raw.kind == .linear_gradient) {
161             if (raw.color.a == 0 and raw.color_end.a == 0) return;
162         } else if (raw.color.a == 0) return;
163         if (empty(raw.rect) or empty(raw.clip)) return;
164         var paint = raw;
165         paint.order = @intCast(self.commands.items.len);
166         try self.commands.append(self.allocator, paint);
167     }
168 
169     pub fn rollback(self: *CommandBuffer, len: usize) void {
170         std.debug.assert(self.coveredCommandCount() <= len);
171         self.commands.shrinkRetainingCapacity(len);
172     }
173 
174     fn coveredCommandCount(self: *const CommandBuffer) usize {
175         const last = if (self.fragments.items.len == 0)
176             return 0
177         else
178             self.fragments.items[self.fragments.items.len - 1];
179         return @as(usize, last.command_start) + last.command_count;
180     }
181 };
182 
183 pub fn assignOrders(commands: []Command) void {
184     for (commands, 0..) |*paint, index| {
185         paint.order = @intCast(index);
186     }
187 }
188 
189 pub fn bounds(paint: Command) Rect {
190     return switch (paint.kind) {
191         .shadow => inset(paint.rect, -@max(paint.width, 0)),
192         else => paint.rect,
193     };
194 }
195 
196 pub fn visibleBounds(paint: Command) Rect {
197     return intersect(bounds(paint), paint.clip);
198 }
199 
200 pub fn visuallyEqual(left: Command, right: Command) bool {
201     return left.kind == right.kind and
202         std.meta.eql(left.rect, right.rect) and
203         std.meta.eql(left.clip, right.clip) and
204         std.meta.eql(left.source, right.source) and
205         std.meta.eql(left.color, right.color) and
206         std.meta.eql(left.color_end, right.color_end) and
207         std.meta.eql(left.gradient_start, right.gradient_start) and
208         std.meta.eql(left.gradient_end, right.gradient_end) and
209         left.radius == right.radius and
210         left.width == right.width and
211         left.image_index == right.image_index;
212 }
213 
214 const Recorder = struct {
215     buffer: *CommandBuffer,
216     clips: [2]Rect,
217     clip_depth: u8 = 1,
218     scale: f32 = 1,
219     failure: ?CommandBuffer.Error = null,
220     fragment_start: ?usize = null,
221     fragment_id: FragmentId = undefined,
222 
223     fn init(buffer: *CommandBuffer, target: Rect, scale: f32) Recorder {
224         return .{
225             .buffer = buffer,
226             .clips = .{ target, undefined },
227             .scale = scale,
228         };
229     }
230 
231     fn scaledRect(self: *const Recorder, rect: Rect) Rect {
232         return .{
233             .x = rect.x * self.scale,
234             .y = rect.y * self.scale,
235             .width = rect.width * self.scale,
236             .height = rect.height * self.scale,
237         };
238     }
239 
240     fn scaledGradientPoint(self: *const Recorder, rect: Rect, point: Point, fallback: Point) Point {
241         const x_unit = finiteOr(point.x, fallback.x);
242         const y_unit = finiteOr(point.y, fallback.y);
243         const fallback_x = rect.x + rect.width * fallback.x;
244         const fallback_y = rect.y + rect.height * fallback.y;
245         return .{
246             .x = finiteOr(rect.x + rect.width * x_unit, fallback_x) * self.scale,
247             .y = finiteOr(rect.y + rect.height * y_unit, fallback_y) * self.scale,
248         };
249     }
250 
251     pub fn beginClip(self: *Recorder, rect: Rect) void {
252         std.debug.assert(self.clip_depth < self.clips.len);
253         self.clips[self.clip_depth] = intersect(self.activeClip(), self.scaledRect(rect));
254         self.clip_depth += 1;
255     }
256 
257     pub fn endClip(self: *Recorder) void {
258         if (self.clip_depth > 1) self.clip_depth -= 1;
259     }
260 
261     pub fn beginWidgetFragment(
262         self: *Recorder,
263         root_id: u64,
264         widget_id: u64,
265     ) void {
266         if (self.failure != null) return;
267         std.debug.assert(self.fragment_start == null);
268         self.fragment_start = self.buffer.commands.items.len;
269         self.fragment_id = .{
270             .root_id = root_id,
271             .element_id = widget_id,
272             .namespace = fragment_namespace_widget,
273             .part = fragment_part_chrome,
274         };
275     }
276 
277     pub fn endFragment(self: *Recorder) void {
278         if (self.failure != null) {
279             self.fragment_start = null;
280             return;
281         }
282         const start = self.fragment_start orelse unreachable;
283         self.fragment_start = null;
284         self.buffer.commitFragment(self.fragment_id, start) catch |failure| {
285             self.failure = failure;
286         };
287     }
288 
289     pub fn fillRect(self: *Recorder, rect: Rect, color: Color) void {
290         self.append(.{
291             .kind = .fill,
292             .rect = self.scaledRect(rect),
293             .clip = self.activeClip(),
294             .color = color,
295         });
296     }
297 
298     pub fn fillRoundedRect(self: *Recorder, rect: Rect, radius: f32, segments: u32, color: Color) void {
299         _ = segments;
300         self.append(.{
301             .kind = .fill,
302             .rect = self.scaledRect(rect),
303             .clip = self.activeClip(),
304             .color = color,
305             .radius = radius * self.scale,
306         });
307     }
308 
309     pub fn fillLinearGradient(self: *Recorder, rect: Rect, radius: f32, segments: u32, gradient: Gradient) void {
310         _ = segments;
311         self.append(.{
312             .kind = .linear_gradient,
313             .rect = self.scaledRect(rect),
314             .clip = self.activeClip(),
315             .color = gradient.start_color,
316             .color_end = gradient.end_color,
317             .gradient_start = self.scaledGradientPoint(rect, gradient.start, .{}),
318             .gradient_end = self.scaledGradientPoint(rect, gradient.end, .{ .x = 1 }),
319             .radius = @max(radius, 0) * self.scale,
320         });
321     }
322 
323     pub fn strokeRect(self: *Recorder, rect: Rect, width: f32, color: Color) void {
324         self.append(.{
325             .kind = .stroke,
326             .rect = self.scaledRect(rect),
327             .clip = self.activeClip(),
328             .color = color,
329             .width = width * self.scale,
330         });
331     }
332 
333     pub fn strokeRoundedRect(self: *Recorder, rect: Rect, radius: f32, segments: u32, width: f32, color: Color) void {
334         _ = segments;
335         self.append(.{
336             .kind = .stroke,
337             .rect = self.scaledRect(rect),
338             .clip = self.activeClip(),
339             .color = color,
340             .radius = radius * self.scale,
341             .width = width * self.scale,
342         });
343     }
344 
345     pub fn drawImage(self: *Recorder, rect: Rect, image_index: u32, source: Rect, opacity: u8) void {
346         self.append(.{
347             .kind = .image,
348             .rect = self.scaledRect(rect),
349             .clip = self.activeClip(),
350             .source = source,
351             .color = .{ .r = 255, .g = 255, .b = 255, .a = opacity },
352             .image_index = image_index,
353         });
354     }
355 
356     pub fn drawGlyph(self: *Recorder, rect: Rect, image_index: u32, source: Rect, color: Color) void {
357         self.append(.{
358             .kind = .glyph,
359             .rect = self.scaledRect(rect),
360             .clip = self.activeClip(),
361             .source = source,
362             .color = color,
363             .image_index = image_index,
364         });
365     }
366 
367     pub fn shadowRoundedRect(self: *Recorder, rect: Rect, radius: f32, blur_radius: f32, spread: f32, offset_x: f32, offset_y: f32, color: Color) void {
368         const expanded = @max(spread, 0);
369         self.append(.{
370             .kind = .shadow,
371             .rect = self.scaledRect(.{
372                 .x = rect.x + offset_x - expanded,
373                 .y = rect.y + offset_y - expanded,
374                 .width = rect.width + expanded * 2,
375                 .height = rect.height + expanded * 2,
376             }),
377             .clip = self.activeClip(),
378             .color = color,
379             .radius = @max(radius + expanded, 0) * self.scale,
380             .width = @max(blur_radius, 0) * self.scale,
381         });
382     }
383 
384     fn append(self: *Recorder, raw: Command) void {
385         if (self.failure != null) return;
386         self.buffer.append(raw) catch |failure| {
387             self.failure = failure;
388         };
389     }
390 
391     fn activeClip(self: *const Recorder) Rect {
392         return self.clips[self.clip_depth - 1];
393     }
394 };
395 
396 fn finiteOr(value: f32, fallback: f32) f32 {
397     return if (std.math.isFinite(value)) value else fallback;
398 }
399 
400 fn fragmentBounds(commands: []const Command) Rect {
401     std.debug.assert(commands.len != 0);
402     var result = visibleBounds(commands[0]);
403     for (commands[1..]) |paint| {
404         result = unionRect(result, visibleBounds(paint));
405     }
406     return result;
407 }
408 
409 pub fn intersect(left: Rect, right: Rect) Rect {
410     const x0 = @max(left.x, right.x);
411     const y0 = @max(left.y, right.y);
412     const x1 = @min(left.x + left.width, right.x + right.width);
413     const y1 = @min(left.y + left.height, right.y + right.height);
414     return .{
415         .x = x0,
416         .y = y0,
417         .width = @max(x1 - x0, 0),
418         .height = @max(y1 - y0, 0),
419     };
420 }
421 
422 fn unionRect(left: Rect, right: Rect) Rect {
423     if (empty(left)) return right;
424     if (empty(right)) return left;
425     const x0 = @min(left.x, right.x);
426     const y0 = @min(left.y, right.y);
427     const x1 = @max(left.x + left.width, right.x + right.width);
428     const y1 = @max(left.y + left.height, right.y + right.height);
429     return .{
430         .x = x0,
431         .y = y0,
432         .width = x1 - x0,
433         .height = y1 - y0,
434     };
435 }
436 
437 pub fn empty(rect: Rect) bool {
438     return rect.width <= 0 or rect.height <= 0;
439 }
440 
441 fn inset(rect: Rect, value: f32) Rect {
442     return .{
443         .x = rect.x + value,
444         .y = rect.y + value,
445         .width = @max(rect.width - value * 2, 0),
446         .height = @max(rect.height - value * 2, 0),
447     };
448 }
449 
450 test "CommandBuffer captures visual widget chrome commands" {
451     const allocator = std.testing.allocator;
452     const child = [_]gui.model.UiNode{.{
453         .widget_id = 2,
454         .kind = .button,
455         .paint = .{
456             .background = .{ .r = 200, .g = 10, .b = 20, .a = 255 },
457             .border = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
458             .border_width = 1,
459             .shadow = .{
460                 .color = .{ .r = 0, .g = 0, .b = 0, .a = 80 },
461                 .offset_y = 1,
462                 .blur_radius = 2,
463             },
464         },
465         .size = .{ .width = 12, .height = 8 },
466     }};
467     const surface = gui.model.UiSurfaceTree{
468         .available_size = .{ .width = 32, .height = 16 },
469         .root = .{
470             .widget_id = 1,
471             .children = child[0..],
472         },
473     };
474     var frame_workspace = gui.frame.Workspace.init(allocator);
475     defer frame_workspace.deinit();
476     const frame = try frame_workspace.buildSurface(&surface, .{});
477 
478     var commands = CommandBuffer.init(allocator);
479     defer commands.deinit();
480     try commands.appendFrame(frame, 1, 32, 16, .{});
481 
482     try std.testing.expect(commands.items().len >= 3);
483     try std.testing.expectEqual(Kind.shadow, commands.items()[0].kind);
484     try std.testing.expectEqual(Kind.fill, commands.items()[1].kind);
485     try std.testing.expectEqual(@as(u32, 0), commands.items()[0].order);
486     try std.testing.expectEqual(@as(u32, 1), commands.items()[1].order);
487     try std.testing.expect(commands.fragmentsComplete());
488     try std.testing.expectEqual(@as(usize, 1), commands.fragmentItems().len);
489     const fragment = commands.fragmentItems()[0];
490     try std.testing.expectEqual(FragmentId{
491         .root_id = 1,
492         .element_id = 2,
493         .namespace = fragment_namespace_widget,
494         .part = fragment_part_chrome,
495     }, fragment.id);
496     try std.testing.expectEqual(
497         Rect{ .width = 14, .height = 11 },
498         fragment.bounds,
499     );
500 }
501 
502 test "CommandBuffer scales chrome geometry to device pixels" {
503     const allocator = std.testing.allocator;
504     const child = [_]gui.model.UiNode{.{
505         .widget_id = 2,
506         .kind = .button,
507         .paint = .{
508             .background = .{ .r = 200, .g = 10, .b = 20, .a = 255 },
509             .border = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
510             .border_width = 1,
511             .corner_roundness = 3,
512         },
513         .size = .{ .width = 12, .height = 8 },
514     }};
515     const surface = gui.model.UiSurfaceTree{
516         .available_size = .{ .width = 32, .height = 16 },
517         .root = .{
518             .widget_id = 1,
519             .children = child[0..],
520         },
521     };
522     var frame_workspace = gui.frame.Workspace.init(allocator);
523     defer frame_workspace.deinit();
524     const frame = try frame_workspace.buildSurface(&surface, .{});
525 
526     var logical = CommandBuffer.init(allocator);
527     defer logical.deinit();
528     try logical.appendFrame(frame, 1, 32, 16, .{});
529     var device = CommandBuffer.init(allocator);
530     defer device.deinit();
531     try device.appendFrame(frame, 1, 64, 32, .{ .scale = 2 });
532 
533     try std.testing.expectEqual(logical.items().len, device.items().len);
534     for (logical.items(), device.items()) |one, two| {
535         try std.testing.expectEqual(one.kind, two.kind);
536         try std.testing.expectEqual(one.rect.x * 2, two.rect.x);
537         try std.testing.expectEqual(one.rect.y * 2, two.rect.y);
538         try std.testing.expectEqual(one.rect.width * 2, two.rect.width);
539         try std.testing.expectEqual(one.rect.height * 2, two.rect.height);
540         try std.testing.expectEqual(one.radius * 2, two.radius);
541         try std.testing.expectEqual(one.width * 2, two.width);
542         try std.testing.expectEqual(one.source.x, two.source.x);
543         try std.testing.expectEqual(one.source.width, two.source.width);
544     }
545 }
546 
547 test "CommandBuffer records normalized linear gradient endpoints in device pixels" {
548     const gradient = gui.model.UiLinearGradient{
549         .start = .{ .x = 0.25, .y = 0.5 },
550         .end = .{ .x = 0.75, .y = 1 },
551         .start_color = .{ .r = 12, .g = 24, .b = 48, .a = 220 },
552         .end_color = .{ .r = 80, .g = 120, .b = 220, .a = 160 },
553     };
554     const child = [_]gui.model.UiNode{.{
555         .widget_id = 2,
556         .kind = .container,
557         .paint = .{
558             .linear_gradient = gradient,
559             .corner_roundness = 3,
560         },
561         .size = .{ .width = 12, .height = 8 },
562     }};
563     const surface = gui.model.UiSurfaceTree{
564         .available_size = .{ .width = 32, .height = 16 },
565         .root = .{ .widget_id = 1, .children = &child },
566     };
567     var frame_workspace = gui.frame.Workspace.init(std.testing.allocator);
568     defer frame_workspace.deinit();
569     const frame = try frame_workspace.buildSurface(&surface, .{});
570     var logical = CommandBuffer.init(std.testing.allocator);
571     defer logical.deinit();
572     try logical.appendFrame(frame, 1, 32, 16, .{});
573     var device = CommandBuffer.init(std.testing.allocator);
574     defer device.deinit();
575     try device.appendFrame(frame, 1, 64, 32, .{ .scale = 2 });
576 
577     try std.testing.expectEqual(@as(usize, 1), logical.items().len);
578     const one = logical.items()[0];
579     const two = device.items()[0];
580     try std.testing.expectEqual(Kind.linear_gradient, one.kind);
581     try std.testing.expectEqual(gradient.start_color, one.color);
582     try std.testing.expectEqual(gradient.end_color, one.color_end);
583     try std.testing.expectEqual(Point{ .x = 3, .y = 4 }, one.gradient_start);
584     try std.testing.expectEqual(Point{ .x = 9, .y = 8 }, one.gradient_end);
585     try std.testing.expectEqual(Point{ .x = 6, .y = 8 }, two.gradient_start);
586     try std.testing.expectEqual(Point{ .x = 18, .y = 16 }, two.gradient_end);
587     try std.testing.expectEqual(@as(f32, 6), two.radius);
588 }
589 
590 test "CommandBuffer keeps a linear gradient with transparent start color" {
591     var commands = CommandBuffer.init(std.testing.allocator);
592     defer commands.deinit();
593     try commands.append(.{
594         .kind = .linear_gradient,
595         .rect = .{ .width = 8, .height = 8 },
596         .clip = .{ .width = 8, .height = 8 },
597         .color = .{ .a = 0 },
598         .color_end = .{ .r = 40, .g = 80, .b = 120, .a = 255 },
599         .gradient_end = .{ .x = 8 },
600     });
601     try std.testing.expectEqual(@as(usize, 1), commands.items().len);
602 }
603 
604 test "assignOrders preserves painter order" {
605     var commands = [_]Command{
606         .{
607             .kind = .fill,
608             .rect = .{ .x = 0, .y = 0, .width = 8, .height = 8 },
609             .clip = .{ .x = 0, .y = 0, .width = 20, .height = 20 },
610             .color = .{ .a = 255 },
611         },
612         .{
613             .kind = .fill,
614             .rect = .{ .x = 10, .y = 0, .width = 4, .height = 4 },
615             .clip = .{ .x = 0, .y = 0, .width = 20, .height = 20 },
616             .color = .{ .a = 255 },
617         },
618         .{
619             .kind = .fill,
620             .rect = .{ .x = 1, .y = 1, .width = 3, .height = 3 },
621             .clip = .{ .x = 0, .y = 0, .width = 20, .height = 20 },
622             .color = .{ .a = 255 },
623         },
624         .{
625             .kind = .shadow,
626             .rect = .{ .x = 12, .y = 1, .width = 2, .height = 2 },
627             .clip = .{ .x = 0, .y = 0, .width = 20, .height = 20 },
628             .color = .{ .a = 255 },
629             .width = 2,
630         },
631     };
632 
633     assignOrders(commands[0..]);
634 
635     try std.testing.expectEqual(@as(u32, 0), commands[0].order);
636     try std.testing.expectEqual(@as(u32, 1), commands[1].order);
637     try std.testing.expectEqual(@as(u32, 2), commands[2].order);
638     try std.testing.expectEqual(@as(u32, 3), commands[3].order);
639 }
640 
641 test "CommandBuffer warmed frames need no backing allocation" {
642     const child = [_]gui.model.UiNode{.{
643         .widget_id = 2,
644         .size = .{ .width = 8, .height = 8 },
645         .paint = .{ .linear_gradient = .{
646             .start = .{ .x = 0.125, .y = 0.25 },
647             .end = .{ .x = 0.875, .y = 0.75 },
648             .start_color = .{ .r = 20, .g = 30, .b = 40, .a = 255 },
649             .end_color = .{ .r = 70, .g = 90, .b = 120, .a = 255 },
650         } },
651     }};
652     const surface = gui.model.UiSurfaceTree{
653         .available_size = .{ .width = 16, .height = 16 },
654         .root = .{ .widget_id = 1, .children = child[0..] },
655     };
656     var frame_workspace = gui.frame.Workspace.init(std.testing.allocator);
657     defer frame_workspace.deinit();
658     const frame = try frame_workspace.buildSurface(&surface, .{});
659     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
660     var commands = CommandBuffer.init(failing.allocator());
661     defer commands.deinit();
662 
663     try commands.appendFrame(frame, 1, 16, 16, .{});
664     const storage = commands.items().ptr;
665     const fragment_storage = commands.fragmentItems().ptr;
666     failing.fail_index = failing.alloc_index;
667     failing.resize_fail_index = failing.resize_index;
668     for (0..8) |_| {
669         commands.reset();
670         try commands.appendFrame(frame, 1, 16, 16, .{});
671         try std.testing.expectEqual(@as(usize, 1), commands.items().len);
672         try std.testing.expectEqual(@as(usize, 1), commands.fragmentItems().len);
673         try std.testing.expectEqual(storage, commands.items().ptr);
674         try std.testing.expectEqual(fragment_storage, commands.fragmentItems().ptr);
675     }
676     try std.testing.expect(!failing.has_induced_failure);
677 }
678 
679 test "CommandBuffer reports allocation failure and rolls back" {
680     const child = [_]gui.model.UiNode{.{
681         .widget_id = 2,
682         .size = .{ .width = 8, .height = 8 },
683         .paint = .{ .background = .{ .r = 20, .g = 30, .b = 40, .a = 255 } },
684     }};
685     const surface = gui.model.UiSurfaceTree{
686         .available_size = .{ .width = 16, .height = 16 },
687         .root = .{ .widget_id = 1, .children = child[0..] },
688     };
689     var frame_workspace = gui.frame.Workspace.init(std.testing.allocator);
690     defer frame_workspace.deinit();
691     const frame = try frame_workspace.buildSurface(&surface, .{});
692     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
693     var commands = CommandBuffer.init(failing.allocator());
694     defer commands.deinit();
695 
696     try std.testing.expectError(error.OutOfMemory, commands.appendFrame(frame, 1, 16, 16, .{}));
697     try std.testing.expectEqual(@as(usize, 0), commands.items().len);
698     try std.testing.expectEqual(@as(usize, 0), commands.fragmentItems().len);
699     failing.fail_index = std.math.maxInt(usize);
700     try commands.appendFrame(frame, 1, 16, 16, .{});
701     try std.testing.expectEqual(@as(usize, 1), commands.items().len);
702     try std.testing.expectEqual(@as(usize, 1), commands.fragmentItems().len);
703 }