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 }