lib/gui/src/visual.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const layout = @import("arrange");
  4 const model = @import("model.zig");
  5 
  6 pub const Rect = layout.Rect;
  7 pub const Color = model.UiColor;
  8 const WidgetFrame = model.WidgetFrame;
  9 
 10 pub const Palette = struct {
 11     panel: Color = .{ .r = 246, .g = 247, .b = 249, .a = 255 },
 12     panel_alt: Color = .{ .r = 235, .g = 238, .b = 242, .a = 255 },
 13     text: Color = .{ .r = 28, .g = 32, .b = 36, .a = 255 },
 14     muted: Color = .{ .r = 99, .g = 108, .b = 118, .a = 255 },
 15     border: Color = .{ .r = 190, .g = 197, .b = 205, .a = 255 },
 16     accent: Color = .{ .r = 30, .g = 96, .b = 184, .a = 255 },
 17     danger: Color = .{ .r = 184, .g = 54, .b = 54, .a = 255 },
 18     focus_fill: Color = .{ .r = 30, .g = 96, .b = 184, .a = 36 },
 19     hover_fill: Color = .{ .r = 30, .g = 96, .b = 184, .a = 22 },
 20     selected_fill: Color = .{ .r = 30, .g = 96, .b = 184, .a = 44 },
 21 };
 22 
 23 pub const Options = struct {
 24     origin_x: f32 = 0,
 25     origin_y: f32 = 0,
 26     palette: Palette = .{},
 27     layer: u8 = 0,
 28 };
 29 
 30 pub fn drawFrame(writer: anytype, frame: model.UiFrame, root_id: u64, options: Options) void {
 31     for (frame.widgets) |widget| {
 32         if (widget.root_id != root_id) continue;
 33         if (widget.layer != options.layer) continue;
 34         writer.beginWidgetFragment(widget.root_id, widget.widget_id);
 35         drawWidgetChrome(writer, widget, options);
 36         writer.endFragment();
 37     }
 38 }
 39 
 40 pub fn drawWidgetChrome(writer: anytype, widget: WidgetFrame, options: Options) void {
 41     if (!widgetHasVisibleChrome(widget)) return;
 42     const rect = offsetRect(widget.rect, options);
 43     const visible = offsetRect(widget.visible_rect, options);
 44     if (isEmpty(rect) or isEmpty(visible)) return;
 45 
 46     const needs_clip = !sameRect(rect, visible);
 47     if (needs_clip) writer.beginClip(visible);
 48     defer if (needs_clip) writer.endClip();
 49 
 50     var background = if (widget.paint.linear_gradient == null)
 51         backgroundColor(widget, options.palette)
 52     else
 53         null;
 54     var border = borderColor(widget, options.palette);
 55     const roundness = @max(widget.paint.corner_roundness, 0);
 56     if (hasState(widget, model.ui_state_selected)) {
 57         const base = background orelse options.palette.panel_alt;
 58         var blended = mix(options.palette.selected_fill, base, alphaUnit(options.palette.selected_fill));
 59         blended.a = base.a;
 60         background = blended;
 61     }
 62     if (hasState(widget, model.ui_state_danger)) {
 63         border = options.palette.danger;
 64     }
 65     if (hasState(widget, model.ui_state_disabled)) {
 66         background = mix(options.palette.panel_alt, background orelse options.palette.panel, 0.55);
 67         border = alpha(border orelse options.palette.border, 120);
 68     }
 69     drawShadow(writer, rect, roundness, widget.paint.shadow);
 70     if (widget.paint.linear_gradient) |gradient| {
 71         writer.fillLinearGradient(rect, roundness, 6, gradient);
 72         if (hasState(widget, model.ui_state_selected)) {
 73             fillPaintedRect(writer, rect, roundness, options.palette.selected_fill);
 74         }
 75     } else if (background) |color| {
 76         fillPaintedRect(writer, rect, roundness, color);
 77     }
 78     if (widget.paint.image) |image| drawImagePaint(writer, rect, image);
 79     const focus_visible = widget.focused and !hasState(widget, model.ui_state_disabled);
 80     if (border) |color| {
 81         if (!focus_visible) strokePaintedRect(writer, rect, roundness, @max(widget.paint.border_width, 1), color);
 82     }
 83     drawEdgeBorders(writer, rect, widget);
 84     drawSemanticState(writer, rect, roundness, widget, options.palette);
 85     drawInteractionState(writer, rect, roundness, widget, options.palette);
 86 }
 87 
 88 fn drawImagePaint(writer: anytype, rect: Rect, image: model.UiImagePaint) void {
 89     if (image.tint) |tint| {
 90         writer.drawGlyph(rect, image.index, image.source, tint);
 91         return;
 92     }
 93     writer.drawImage(rect, image.index, image.source, image.opacity);
 94 }
 95 
 96 pub fn widgetHasVisibleChrome(widget: WidgetFrame) bool {
 97     if (widget.paint.background != null) return true;
 98     if (widget.paint.linear_gradient != null) return true;
 99     if (widget.paint.image != null) return true;
100     if (widget.paint.border != null and widget.paint.border_width > 0) return true;
101     if (edgeVisible(widget.paint.border_edges.top)) return true;
102     if (edgeVisible(widget.paint.border_edges.right)) return true;
103     if (edgeVisible(widget.paint.border_edges.bottom)) return true;
104     if (edgeVisible(widget.paint.border_edges.left)) return true;
105     if (shadowVisible(widget.paint.shadow)) return true;
106     if (widget.kind == .button or widget.kind == .text_input) return true;
107     if (widget.state_flags != 0) return true;
108     if (widget.hovered or widget.focused or widget.captured) return true;
109     return false;
110 }
111 
112 fn alphaUnit(color: Color) f32 {
113     return @as(f32, @floatFromInt(color.a)) / 255.0;
114 }
115 
116 fn backgroundColor(widget: WidgetFrame, palette: Palette) ?Color {
117     if (widget.paint.background) |color| return color;
118     return switch (widget.kind) {
119         .button => alpha(palette.panel_alt, 220),
120         .text_input => alpha(palette.panel, 245),
121         else => null,
122     };
123 }
124 
125 fn borderColor(widget: WidgetFrame, palette: Palette) ?Color {
126     if (widget.paint.border) |color| return color;
127     if (widget.paint.background != null or widget.paint.linear_gradient != null) return null;
128     return switch (widget.kind) {
129         .button, .text_input => alpha(palette.border, 190),
130         else => null,
131     };
132 }
133 
134 fn drawSemanticState(writer: anytype, rect: Rect, roundness: f32, widget: WidgetFrame, palette: Palette) void {
135     if (hasState(widget, model.ui_state_selected)) {
136         writer.fillRect(.{ .x = rect.x, .y = rect.y, .width = @min(3, rect.width), .height = rect.height }, palette.accent);
137     }
138     if (hasState(widget, model.ui_state_default)) {
139         writer.fillRect(.{ .x = rect.x + 4, .y = rect.y, .width = @max(rect.width - 8, 0), .height = @min(2, rect.height) }, alpha(palette.accent, 190));
140     }
141     if (hasState(widget, model.ui_state_expanded)) {
142         writer.fillRect(.{ .x = rect.x + rect.width - 7, .y = rect.y + 6, .width = 3, .height = @max(rect.height - 12, 0) }, alpha(palette.accent, 170));
143     }
144     if (hasState(widget, model.ui_state_checked)) {
145         const mark = Rect{ .x = rect.x + rect.width - 12, .y = rect.y + 6, .width = 6, .height = 6 };
146         writer.fillRect(mark, palette.accent);
147         writer.fillRect(.{ .x = mark.x + 2, .y = mark.y + 2, .width = @max(mark.width - 2, 0), .height = 2 }, palette.accent);
148     }
149     if (hasState(widget, model.ui_state_disabled)) {
150         fillPaintedRect(writer, inset(rect, 1, 1), insetRoundness(roundness, 1), alpha(palette.panel, 92));
151     }
152 }
153 
154 fn drawInteractionState(writer: anytype, rect: Rect, roundness: f32, widget: WidgetFrame, palette: Palette) void {
155     if (hasState(widget, model.ui_state_disabled)) return;
156     if (widget.captured) fillPaintedRect(writer, inset(rect, 1, 1), insetRoundness(roundness, 1), alpha(palette.accent, 42));
157     if (widget.hovered) fillPaintedRect(writer, inset(rect, 1, 1), insetRoundness(roundness, 1), palette.hover_fill);
158     if (widget.focused) {
159         fillPaintedRect(writer, inset(rect, 2, 2), insetRoundness(roundness, 2), palette.focus_fill);
160         strokePaintedRect(
161             writer,
162             inset(rect, -2, -2),
163             insetRoundness(roundness, -2),
164             1.4,
165             alpha(palette.accent, 220),
166         );
167     }
168 }
169 
170 fn drawEdgeBorders(writer: anytype, rect: Rect, widget: WidgetFrame) void {
171     drawTopEdge(writer, rect, widget.paint.border_edges.top);
172     drawRightEdge(writer, rect, widget.paint.border_edges.right);
173     drawBottomEdge(writer, rect, widget.paint.border_edges.bottom);
174     drawLeftEdge(writer, rect, widget.paint.border_edges.left);
175 }
176 
177 fn drawTopEdge(writer: anytype, rect: Rect, edge: model.UiBorderEdgePaint) void {
178     const color = edgeColor(edge) orelse return;
179     if (edge.width <= 0) return;
180     writer.fillRect(.{ .x = rect.x, .y = rect.y, .width = rect.width, .height = edge.width }, color);
181 }
182 
183 fn drawRightEdge(writer: anytype, rect: Rect, edge: model.UiBorderEdgePaint) void {
184     const color = edgeColor(edge) orelse return;
185     if (edge.width <= 0) return;
186     writer.fillRect(.{ .x = rect.x + rect.width - edge.width, .y = rect.y, .width = edge.width, .height = rect.height }, color);
187 }
188 
189 fn drawBottomEdge(writer: anytype, rect: Rect, edge: model.UiBorderEdgePaint) void {
190     const color = edgeColor(edge) orelse return;
191     if (edge.width <= 0) return;
192     writer.fillRect(.{ .x = rect.x, .y = rect.y + rect.height - edge.width, .width = rect.width, .height = edge.width }, color);
193 }
194 
195 fn drawLeftEdge(writer: anytype, rect: Rect, edge: model.UiBorderEdgePaint) void {
196     const color = edgeColor(edge) orelse return;
197     if (edge.width <= 0) return;
198     writer.fillRect(.{ .x = rect.x, .y = rect.y, .width = edge.width, .height = rect.height }, color);
199 }
200 
201 fn drawShadow(writer: anytype, rect: Rect, roundness: f32, shadow: model.UiShadowPaint) void {
202     const color = shadow.color orelse return;
203     if (color.a == 0 or isEmpty(rect)) return;
204     writer.shadowRoundedRect(rect, roundness, @max(shadow.blur_radius, 0), @max(shadow.spread, 0), shadow.offset_x, shadow.offset_y, color);
205 }
206 
207 fn fillPaintedRect(writer: anytype, rect: Rect, roundness: f32, color: Color) void {
208     if (color.a == 0 or isEmpty(rect)) return;
209     if (roundness > 0) {
210         writer.fillRoundedRect(rect, roundness, 6, color);
211     } else {
212         writer.fillRect(rect, color);
213     }
214 }
215 
216 fn strokePaintedRect(writer: anytype, rect: Rect, roundness: f32, width: f32, color: Color) void {
217     if (color.a == 0 or width <= 0 or isEmpty(rect)) return;
218     if (roundness > 0) {
219         writer.strokeRoundedRect(rect, roundness, 6, width, color);
220     } else {
221         writer.strokeRect(rect, width, color);
222     }
223 }
224 
225 fn edgeVisible(edge: model.UiBorderEdgePaint) bool {
226     return edge.color != null and edge.width > 0;
227 }
228 
229 fn shadowVisible(shadow: model.UiShadowPaint) bool {
230     const color = shadow.color orelse return false;
231     return color.a != 0;
232 }
233 
234 fn edgeColor(edge: model.UiBorderEdgePaint) ?Color {
235     if (edge.color) |color| return color;
236     return null;
237 }
238 
239 fn offsetRect(rect: Rect, options: Options) Rect {
240     return .{
241         .x = options.origin_x + rect.x,
242         .y = options.origin_y + rect.y,
243         .width = rect.width,
244         .height = rect.height,
245     };
246 }
247 
248 fn inset(rect: Rect, dx: f32, dy: f32) Rect {
249     return .{
250         .x = rect.x + dx,
251         .y = rect.y + dy,
252         .width = @max(rect.width - dx * 2, 0),
253         .height = @max(rect.height - dy * 2, 0),
254     };
255 }
256 
257 fn insetRoundness(roundness: f32, amount: f32) f32 {
258     return @max(roundness - amount, 0);
259 }
260 
261 fn sameRect(left: Rect, right: Rect) bool {
262     return left.x == right.x and left.y == right.y and left.width == right.width and left.height == right.height;
263 }
264 
265 fn isEmpty(rect: Rect) bool {
266     return rect.width <= 0 or rect.height <= 0;
267 }
268 
269 fn hasState(widget: WidgetFrame, flag: u64) bool {
270     return (widget.state_flags & flag) != 0;
271 }
272 
273 fn alpha(color: Color, value: u8) Color {
274     return .{ .r = color.r, .g = color.g, .b = color.b, .a = value };
275 }
276 
277 fn mix(left: Color, right: Color, left_weight: f32) Color {
278     const clamped = std.math.clamp(left_weight, @as(f32, 0), @as(f32, 1));
279     return .{
280         .r = mixChannel(left.r, right.r, clamped),
281         .g = mixChannel(left.g, right.g, clamped),
282         .b = mixChannel(left.b, right.b, clamped),
283         .a = mixChannel(left.a, right.a, clamped),
284     };
285 }
286 
287 fn mixChannel(left: u8, right: u8, left_weight: f32) u8 {
288     const value = @as(f32, @floatFromInt(left)) * left_weight +
289         @as(f32, @floatFromInt(right)) * (1 - left_weight);
290     return @intFromFloat(std.math.clamp(@round(value), @as(f32, 0), @as(f32, 255)));
291 }
292 
293 const RecorderDraw = struct {
294     rect: Rect,
295     width: f32 = 0,
296     color: Color,
297     blur_radius: f32 = 0,
298     spread: f32 = 0,
299     offset_x: f32 = 0,
300     offset_y: f32 = 0,
301 };
302 
303 const RecorderBlit = struct {
304     rect: Rect,
305     image_index: u32,
306     color: ?Color = null,
307     opacity: u8 = 255,
308 };
309 
310 const Recorder = struct {
311     fills: usize = 0,
312     gradients: usize = 0,
313     strokes: usize = 0,
314     shadows: usize = 0,
315     clip_begins: usize = 0,
316     clip_ends: usize = 0,
317     images: usize = 0,
318     glyphs: usize = 0,
319     saw_accent_fill: bool = false,
320     saw_focus_stroke: bool = false,
321     last_clip: ?Rect = null,
322     last_fill: ?RecorderDraw = null,
323     last_fill_roundness: f32 = 0,
324     last_gradient: ?model.UiLinearGradient = null,
325     last_stroke: ?RecorderDraw = null,
326     last_stroke_roundness: f32 = 0,
327     last_shadow: ?RecorderDraw = null,
328     last_blit: ?RecorderBlit = null,
329 
330     fn beginClip(self: *Recorder, rect: Rect) void {
331         self.clip_begins += 1;
332         self.last_clip = rect;
333     }
334 
335     fn endClip(self: *Recorder) void {
336         self.clip_ends += 1;
337     }
338 
339     fn fillRect(self: *Recorder, rect: Rect, color: Color) void {
340         self.last_fill_roundness = 0;
341         self.recordFill(rect, color);
342     }
343 
344     fn fillRoundedRect(self: *Recorder, rect: Rect, roundness: f32, segments: u32, color: Color) void {
345         _ = segments;
346         self.last_fill_roundness = roundness;
347         self.recordFill(rect, color);
348     }
349 
350     fn fillLinearGradient(self: *Recorder, rect: Rect, roundness: f32, segments: u32, gradient: model.UiLinearGradient) void {
351         _ = rect;
352         _ = roundness;
353         _ = segments;
354         self.gradients += 1;
355         self.last_gradient = gradient;
356     }
357 
358     fn strokeRect(self: *Recorder, rect: Rect, width: f32, color: Color) void {
359         self.last_stroke_roundness = 0;
360         self.recordStroke(rect, width, color);
361     }
362 
363     fn strokeRoundedRect(self: *Recorder, rect: Rect, roundness: f32, segments: u32, width: f32, color: Color) void {
364         _ = segments;
365         self.last_stroke_roundness = roundness;
366         self.recordStroke(rect, width, color);
367     }
368 
369     fn shadowRoundedRect(self: *Recorder, rect: Rect, roundness: f32, blur_radius: f32, spread: f32, offset_x: f32, offset_y: f32, color: Color) void {
370         _ = roundness;
371         self.shadows += 1;
372         self.last_shadow = .{ .rect = rect, .color = color, .blur_radius = blur_radius, .spread = spread, .offset_x = offset_x, .offset_y = offset_y };
373     }
374 
375     fn recordFill(self: *Recorder, rect: Rect, color: Color) void {
376         self.fills += 1;
377         self.last_fill = .{ .rect = rect, .color = color };
378         if (color.a != 0 and color.b > color.r) self.saw_accent_fill = true;
379     }
380 
381     fn recordStroke(self: *Recorder, rect: Rect, width: f32, color: Color) void {
382         self.strokes += 1;
383         self.last_stroke = .{ .rect = rect, .width = width, .color = color };
384         if (width > 1 and color.a != 0 and color.b > color.r) self.saw_focus_stroke = true;
385     }
386 
387     fn drawImage(self: *Recorder, rect: Rect, image_index: u32, source: Rect, opacity: u8) void {
388         _ = source;
389         self.images += 1;
390         self.last_blit = .{ .rect = rect, .image_index = image_index, .opacity = opacity };
391     }
392 
393     fn drawGlyph(self: *Recorder, rect: Rect, image_index: u32, source: Rect, color: Color) void {
394         _ = source;
395         self.glyphs += 1;
396         self.last_blit = .{ .rect = rect, .image_index = image_index, .color = color };
397     }
398 };
399 
400 test "widget chrome emits semantic and interaction state" {
401     var recorder = Recorder{};
402     drawWidgetChrome(&recorder, .{
403         .root_id = 1,
404         .widget_id = 2,
405         .kind = .button,
406         .rect = .{ .x = 20, .y = 12, .width = 36, .height = 16 },
407         .visible_rect = .{ .x = 20, .y = 12, .width = 36, .height = 16 },
408         .paint = .{ .corner_roundness = 6 },
409         .scroll = .{},
410         .constraints = .{},
411         .content_size = .{ .width = 36, .height = 16 },
412         .focusable = true,
413         .focused = true,
414         .state_flags = model.ui_state_selected | model.ui_state_default,
415     }, .{});
416 
417     try std.testing.expectEqual(@as(usize, 1), recorder.strokes);
418     try std.testing.expect(recorder.saw_accent_fill);
419     try std.testing.expect(recorder.saw_focus_stroke);
420     try std.testing.expectEqual(@as(f32, 4), recorder.last_fill_roundness);
421     try std.testing.expectEqual(Rect{ .x = 22, .y = 14, .width = 32, .height = 12 }, recorder.last_fill.?.rect);
422     try std.testing.expectEqual(@as(f32, 8), recorder.last_stroke_roundness);
423     try std.testing.expectEqual(Rect{ .x = 18, .y = 10, .width = 40, .height = 20 }, recorder.last_stroke.?.rect);
424 }
425 
426 test "widget chrome preserves nonfocused border and compensates state inset roundness" {
427     var recorder = Recorder{};
428     const widget = WidgetFrame{
429         .root_id = 1,
430         .widget_id = 2,
431         .kind = .button,
432         .rect = .{ .x = 8, .y = 6, .width = 48, .height = 20 },
433         .visible_rect = .{ .x = 8, .y = 6, .width = 48, .height = 20 },
434         .paint = .{ .corner_roundness = 6 },
435         .scroll = .{},
436         .constraints = .{},
437         .content_size = .{ .width = 48, .height = 20 },
438         .focusable = true,
439         .hovered = true,
440         .captured = true,
441     };
442     drawWidgetChrome(&recorder, widget, .{});
443 
444     try std.testing.expectEqual(@as(usize, 1), recorder.strokes);
445     try std.testing.expectEqual(widget.rect, recorder.last_stroke.?.rect);
446     try std.testing.expectEqual(@as(f32, 6), recorder.last_stroke_roundness);
447     try std.testing.expectEqual(inset(widget.rect, 1, 1), recorder.last_fill.?.rect);
448     try std.testing.expectEqual(@as(f32, 5), recorder.last_fill_roundness);
449 }
450 
451 test "widget chrome contains checked marker bar" {
452     var recorder = Recorder{};
453     drawWidgetChrome(&recorder, .{
454         .root_id = 1,
455         .widget_id = 2,
456         .kind = .container,
457         .rect = .{ .x = 20, .y = 12, .width = 36, .height = 16 },
458         .visible_rect = .{ .x = 20, .y = 12, .width = 36, .height = 16 },
459         .paint = .{},
460         .scroll = .{},
461         .constraints = .{},
462         .content_size = .{ .width = 36, .height = 16 },
463         .focusable = false,
464         .state_flags = model.ui_state_checked,
465     }, .{});
466 
467     try std.testing.expectEqual(@as(usize, 2), recorder.fills);
468     try std.testing.expectEqual(Rect{ .x = 46, .y = 20, .width = 4, .height = 2 }, recorder.last_fill.?.rect);
469 }
470 
471 test "widget chrome preserves linear gradient under state overlays" {
472     const gradient = model.UiLinearGradient{
473         .start = .{ .x = 0, .y = 0.2 },
474         .end = .{ .x = 1, .y = 0.8 },
475         .start_color = .{ .r = 20, .g = 40, .b = 80, .a = 255 },
476         .end_color = .{ .r = 70, .g = 120, .b = 220, .a = 255 },
477     };
478     var recorder = Recorder{};
479     drawWidgetChrome(&recorder, .{
480         .root_id = 1,
481         .widget_id = 2,
482         .kind = .button,
483         .rect = .{ .x = 8, .y = 6, .width = 48, .height = 20 },
484         .visible_rect = .{ .x = 8, .y = 6, .width = 48, .height = 20 },
485         .paint = .{
486             .background = .{ .r = 255, .g = 0, .b = 0, .a = 255 },
487             .linear_gradient = gradient,
488             .corner_roundness = 6,
489         },
490         .scroll = .{},
491         .constraints = .{},
492         .content_size = .{ .width = 48, .height = 20 },
493         .focusable = true,
494         .hovered = true,
495         .focused = true,
496         .state_flags = model.ui_state_selected,
497     }, .{});
498 
499     try std.testing.expectEqual(@as(usize, 1), recorder.gradients);
500     try std.testing.expectEqual(gradient, recorder.last_gradient.?);
501     try std.testing.expect(recorder.fills >= 3);
502     try std.testing.expect(recorder.saw_accent_fill);
503     try std.testing.expect(recorder.saw_focus_stroke);
504     try std.testing.expectEqual(@as(f32, 8), recorder.last_stroke_roundness);
505 }
506 
507 test "widget chrome blits image paint tinted and untinted" {
508     var recorder = Recorder{};
509     drawWidgetChrome(&recorder, .{
510         .root_id = 1,
511         .widget_id = 2,
512         .kind = .container,
513         .rect = .{ .x = 4, .y = 4, .width = 64, .height = 64 },
514         .visible_rect = .{ .x = 4, .y = 4, .width = 64, .height = 64 },
515         .paint = .{ .image = .{ .index = 3, .opacity = 200 } },
516         .scroll = .{},
517         .constraints = .{},
518         .content_size = .{ .width = 64, .height = 64 },
519         .focusable = false,
520     }, .{});
521     try std.testing.expectEqual(@as(usize, 1), recorder.images);
522     try std.testing.expectEqual(@as(u32, 3), recorder.last_blit.?.image_index);
523     try std.testing.expectEqual(@as(u8, 200), recorder.last_blit.?.opacity);
524 
525     drawWidgetChrome(&recorder, .{
526         .root_id = 1,
527         .widget_id = 3,
528         .kind = .container,
529         .rect = .{ .x = 4, .y = 4, .width = 64, .height = 64 },
530         .visible_rect = .{ .x = 4, .y = 4, .width = 64, .height = 64 },
531         .paint = .{ .image = .{ .index = 5, .tint = .{ .r = 251, .g = 73, .b = 52 } } },
532         .scroll = .{},
533         .constraints = .{},
534         .content_size = .{ .width = 64, .height = 64 },
535         .focusable = false,
536     }, .{});
537     try std.testing.expectEqual(@as(usize, 1), recorder.glyphs);
538     try std.testing.expectEqual(@as(u32, 5), recorder.last_blit.?.image_index);
539     try std.testing.expectEqual(@as(u8, 251), recorder.last_blit.?.color.?.r);
540 }
541 
542 test "widget chrome emits configured shadow" {
543     var recorder = Recorder{};
544     drawWidgetChrome(&recorder, .{
545         .root_id = 1,
546         .widget_id = 2,
547         .kind = .container,
548         .rect = .{ .x = 6, .y = 8, .width = 30, .height = 12 },
549         .visible_rect = .{ .x = 0, .y = 0, .width = 64, .height = 32 },
550         .paint = .{
551             .background = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
552             .corner_roundness = 0.4,
553             .shadow = .{
554                 .color = .{ .r = 0, .g = 0, .b = 0, .a = 80 },
555                 .offset_x = 2,
556                 .offset_y = 3,
557                 .blur_radius = 4,
558                 .spread = 1,
559             },
560         },
561         .scroll = .{},
562         .constraints = .{},
563         .content_size = .{ .width = 30, .height = 12 },
564         .focusable = false,
565     }, .{});
566 
567     try std.testing.expectEqual(@as(usize, 1), recorder.shadows);
568     try std.testing.expectEqual(@as(f32, 4), recorder.last_shadow.?.blur_radius);
569     try std.testing.expectEqual(@as(f32, 2), recorder.last_shadow.?.offset_x);
570     try std.testing.expectEqual(@as(usize, 1), recorder.fills);
571 }
572 
573 test "widget chrome clips to visible rect" {
574     var recorder = Recorder{};
575     drawWidgetChrome(&recorder, .{
576         .root_id = 1,
577         .widget_id = 2,
578         .kind = .button,
579         .rect = .{ .x = 8, .y = 8, .width = 30, .height = 12 },
580         .visible_rect = .{ .x = 20, .y = 8, .width = 18, .height = 12 },
581         .paint = .{ .background = .{ .r = 255, .g = 0, .b = 0, .a = 255 } },
582         .scroll = .{},
583         .constraints = .{},
584         .content_size = .{ .width = 30, .height = 12 },
585         .focusable = true,
586     }, .{});
587 
588     try std.testing.expectEqual(@as(usize, 1), recorder.clip_begins);
589     try std.testing.expectEqual(@as(usize, 1), recorder.clip_ends);
590     try std.testing.expectEqual(@as(f32, 20), recorder.last_clip.?.x);
591     try std.testing.expectEqual(@as(f32, 18), recorder.last_clip.?.width);
592 }