const std = @import("std"); const dvui = @import("dvui"); const dvui_ext = @import("dvui_ext.zig"); const Canvas = @import("../Canvas.zig"); const Document = @import("../models/Document.zig"); const Property = @import("../models/Property.zig").Property; const PropertyData = @import("../models/Property.zig").Data; const Rect_i = @import("../models/basic_models.zig").Rect_i; const Point2_f = @import("../models/basic_models.zig").Point2_f; const Tool = @import("../toolbar/Tool.zig"); const RenderStats = @import("../render/RenderStats.zig"); const icons = @import("../icons.zig"); pub fn canvasView(canvas: *Canvas, selected_object_id: ?u64, content_rect_scale: dvui.RectScale) void { var textured = dvui_ext.texturedBox(content_rect_scale, dvui.Rect.all(20)); { var overlay = dvui.overlay(@src(), .{ .expand = .both }); { const overlay_parent = dvui.parentGet(); const init_options: dvui.ScrollAreaWidget.InitOpts = .{ .scroll_info = &canvas.scroll, .vertical_bar = .auto, .horizontal_bar = .auto, .process_events_after = false, }; var scroll = dvui.scrollArea( @src(), init_options, .{ .expand = .both, .background = false, }, ); { drawCanvasContent(canvas, scroll); handleCanvasZoom(canvas, scroll); handleCanvasMouse(canvas, scroll, selected_object_id); } const scroll_parent = dvui.parentGet(); dvui.parentSet(overlay_parent); const vbar = scroll.vbar; const hbar = scroll.hbar; if (vbar != null) { // std.debug.print("{any}", .{vbar.?.data()}); } if (hbar != null) { // std.debug.print("{any}", .{hbar.?.data()}); } // Тулбар поверх scroll var toolbar_box = dvui.box( @src(), .{ .dir = .horizontal }, .{}, ); { drawToolbar(canvas); // Сохраняем rect тулбара для следующего кадра — в handleCanvasMouse исключаем из него клики canvas.toolbar_rect_scale = toolbar_box.data().contentRectScale(); } toolbar_box.deinit(); // Панель свойств поверх scroll (правый верхний угол) if (selected_object_id) |obj_id| { if (canvas.document.findObjectById(obj_id)) |obj| { var properties_box = dvui.box( @src(), .{ .dir = .horizontal }, .{ .gravity_x = 1.0, .gravity_y = 0.0, }, ); { drawPropertiesPanel(canvas, obj); // Сохраняем rect панели свойств для следующего кадра — в handleCanvasMouse исключаем из него клики canvas.properties_rect_scale = properties_box.data().contentRectScale(); } properties_box.deinit(); } } drawCanvasLabelPanel(); if (canvas.show_render_stats) drawStatsPanel(canvas.render_engine.getStats(), canvas.frame_index); if (canvas.properties_rect_scale) |prs| { for (dvui.events()) |*e| { if (e.handled) continue; if (e.evt != .mouse) continue; const mouse = &e.evt.mouse; if (mouse.action != .wheel_x and mouse.action != .wheel_y) continue; const pt = prs.pointFromPhysical(mouse.p); const r = prs.r; if (pt.x >= 0 and pt.x * prs.s < r.w and pt.y >= 0 and pt.y * prs.s < r.h) { e.handled = true; } } } if (!init_options.process_events_after) { if (scroll.scroll) |*sc| { dvui.clipSet(sc.prevClip); sc.processEventsAfter(); } } dvui.parentSet(scroll_parent); scroll.deinit(); } overlay.deinit(); } textured.deinit(); } fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void { const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); const img_size = canvas.getZoomedImageSize(); const viewport_rect = scroll.data().contentRect(); const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y }; const viewport_px = dvui.Rect{ .x = viewport_rect.x * natural_scale, .y = viewport_rect.y * natural_scale, .w = viewport_rect.w * natural_scale, .h = viewport_rect.h * natural_scale, }; const scroll_px = dvui.Point{ .x = scroll_current.x * natural_scale, .y = scroll_current.y * natural_scale, }; const changed = canvas.updateVisibleImageRect(viewport_px, scroll_px); if (changed) canvas.requestRedraw(); canvas.processPendingRedraw() catch |err| { std.debug.print("processPendingRedraw error: {}\n", .{err}); }; const content_w_px: u32 = img_size.x + img_size.w; const content_h_px: u32 = img_size.y + img_size.h; const content_w = @as(f32, @floatFromInt(content_w_px)) / natural_scale; const content_h = @as(f32, @floatFromInt(content_h_px)) / natural_scale; var canvas_layer = dvui.overlay( @src(), .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, ); { if (canvas.texture) |tex| { const vis = canvas._visible_rect orelse Rect_i{ .x = 0, .y = 0, .w = 0, .h = 0 }; const left = @as(f32, @floatFromInt(img_size.x + vis.x)) / natural_scale; const top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale; _ = dvui.image( @src(), .{ .source = .{ .texture = tex } }, .{ .background = false, .expand = .none, .gravity_x = 0.0, .gravity_y = 0.0, .margin = .{ .x = left, .y = top, .w = canvas.pos.x, .h = canvas.pos.y }, .min_size_content = .{ .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, }, .max_size_content = .{ .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, }, }, ); } } canvas_layer.deinit(); } fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void { const ctrl = dvui.currentWindow().modifiers.control(); if (!ctrl) return; const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); for (dvui.events()) |*e| { switch (e.evt) { .mouse => |*mouse| { const action = mouse.action; if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) { switch (action) { .wheel_y => |y| { const viewport_pt = scroll.data().contentRectScale().pointFromPhysical(mouse.p); const content_pt = dvui.Point{ .x = viewport_pt.x + canvas.scroll.viewport.x, .y = viewport_pt.y + canvas.scroll.viewport.y, }; const doc_pt = canvas.contentPointToDocument(content_pt, natural_scale); // canvas.addZoom(y / 1000); canvas.multZoom(1 + y / 2000); canvas.requestRedraw(); const doc_pt_after = canvas.contentPointToDocument(content_pt, natural_scale); const zoom = canvas.getZoom(); const dx = (doc_pt_after.x - doc_pt.x) * zoom / natural_scale; const dy = (doc_pt_after.y - doc_pt.y) * zoom / natural_scale; canvas.scroll.viewport.x -= dx; canvas.scroll.viewport.y -= dy; }, else => {}, } e.handled = true; } }, else => {}, } } } fn handleCanvasMouse(canvas: *Canvas, scroll: *dvui.ScrollAreaWidget, selected_object_id: ?u64) void { const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); const scroll_data = scroll.data(); for (dvui.events()) |*e| { switch (e.evt) { .mouse => |*mouse| { if (mouse.action != .press or mouse.button != .left) continue; if (e.handled) continue; if (!dvui.eventMatchSimple(e, scroll_data)) continue; // Не обрабатывать клик, если он попал в область тулбара (rect с предыдущего кадра). if (canvas.toolbar_rect_scale) |trs| { const pt = trs.pointFromPhysical(mouse.p); const r = trs.r; if (pt.x >= 0 and pt.x * trs.s < r.w and pt.y >= 0 and pt.y * trs.s < r.h) continue; } // Не обрабатывать клик, если он попал в область панели свойств (rect с предыдущего кадра). if (canvas.properties_rect_scale) |prs| { const pt = prs.pointFromPhysical(mouse.p); const r = prs.r; if (pt.x >= 0 and pt.x * prs.s < r.w and pt.y >= 0 and pt.y * prs.s < r.h) continue; } const viewport_pt = scroll_data.contentRectScale().pointFromPhysical(mouse.p); const content_pt = dvui.Point{ .x = viewport_pt.x + canvas.scroll.viewport.x, .y = viewport_pt.y + canvas.scroll.viewport.y, }; const doc_pt = canvas.contentPointToDocument(content_pt, natural_scale); canvas.cursor_document_point = if (canvas.isContentPointOnDocument(content_pt, natural_scale)) doc_pt else null; if (canvas.cursor_document_point) |point| { if (canvas.toolbar.currentDescriptor()) |desc| { var ctx = Tool.ToolContext{ .canvas = canvas, .document_point = point, .selected_object_id = selected_object_id, }; desc.implementation.onCanvasClick(&ctx) catch |err| { std.debug.print("onCanvasClick error: {}\n", .{err}); }; } } }, else => {}, } } } fn drawToolbar(canvas: *Canvas) void { const tools_list = canvas.toolbar.tools; if (tools_list.len == 0) return; var bar = dvui.box( @src(), .{ .dir = .vertical }, .{ .gravity_x = 0.0, .gravity_y = 0.0, .padding = dvui.Rect.all(6), .corner_radius = dvui.Rect.all(8), .background = true, .color_fill = dvui.Color.black.opacity(0.2), .margin = dvui.Rect{ .x = 16, .y = 16 }, }, ); { var to_select: ?usize = null; for (tools_list, 0..) |*tool_desc, i| { const is_selected = canvas.toolbar.selected_index == i; const selected_fill = dvui.themeGet().focus; const opts: dvui.Options = .{ .id_extra = i, .color_fill = if (is_selected) selected_fill else null, }; if (dvui.buttonIcon(@src(), tool_desc.name, tool_desc.icon_tvg, .{}, .{}, opts)) { to_select = i; } } if (to_select) |index| { canvas.toolbar.select(index); } } bar.deinit(); } fn drawPropertiesPanel(canvas: *Canvas, selected_object: *Document.Object) void { var panel = dvui.box( @src(), .{ .dir = .vertical }, .{ .padding = dvui.Rect.all(8), .corner_radius = dvui.Rect.all(8), .background = true, .color_fill = dvui.Color.black.opacity(0.2), .min_size_content = .width(300), .max_size_content = .width(300), .margin = dvui.Rect{ .w = 32, .y = 16, .h = 100 }, }, ); { dvui.label(@src(), "Properties", .{}, .{}); var scroll = dvui.scrollArea(@src(), .{ .horizontal = .none, .vertical = .auto, }, .{ .expand = .both, }); { for (selected_object.properties.items, 0..) |*prop, i| { drawPropertyEditor(canvas, selected_object, prop, i); } } scroll.deinit(); } panel.deinit(); } fn drawCanvasLabelPanel() void { var panel = dvui.box( @src(), .{ .dir = .vertical }, .{ .gravity_x = 0.5, .gravity_y = 0.0, .padding = dvui.Rect.all(8), .corner_radius = dvui.Rect.all(8), .background = true, .color_fill = dvui.Color.black.opacity(0.2), .margin = dvui.Rect{ .x = 16, .y = 16 }, }, ); { dvui.label(@src(), "Canvas", .{}, .{}); } panel.deinit(); } fn drawStatsPanel(stats: RenderStats, frame_index: u64) void { var panel = dvui.box( @src(), .{ .dir = .vertical }, .{ .gravity_x = 0.0, .gravity_y = 1.0, .padding = dvui.Rect.all(8), .corner_radius = dvui.Rect.all(8), .background = true, .color_fill = dvui.Color.black.opacity(0.2), .margin = dvui.Rect{ .x = 16, .h = 16 }, }, ); { dvui.label(@src(), "Frame time: {d:.2}ms", .{@as(f32, @floatFromInt(stats.render_time_ns)) / std.time.ns_per_ms}, .{}); dvui.label(@src(), "Frame index: {}", .{frame_index}, .{}); } panel.deinit(); } fn applyPropertyPatch(canvas: *Canvas, obj: *Document.Object, patch: Property) void { obj.setProperty(canvas.allocator, patch) catch {}; canvas.requestRedraw(); } fn drawPropertyEditor(canvas: *Canvas, obj: *Document.Object, prop: *const Property, row_index: usize) void { const row_id: usize = row_index * 16; const is_even = row_index % 2 == 0; var row = dvui.box( @src(), .{ .dir = .vertical }, .{ .id_extra = row_id, .expand = .horizontal, .padding = dvui.Rect{ .y = 2, .x = 4 }, .corner_radius = dvui.Rect.all(4), .background = is_even, .color_fill = if (is_even) dvui.Color.black.opacity(0.4) else .{}, }, ); { const tag = std.meta.activeTag(prop.data); dvui.labelNoFmt(@src(), propertyLabel(tag), .{}, .{}); switch (prop.data) { .position => |pos| { var next = pos; var changed = false; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "x:", .{}, .{}); const T = @TypeOf(next.x); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "y:", .{}, .{}); const T = @TypeOf(next.y); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } if (changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .position = next } }); } }, .angle => |angle| { var next = angle; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "deg:", .{}, .{}); var degrees: f32 = next * 180.0 / std.math.pi; const res = dvui.textEntryNumber(@src(), f32, .{ .value = °rees }, .{ .expand = .horizontal }); subrow.deinit(); if (res.changed) { next = degrees * std.math.pi / 180.0; applyPropertyPatch(canvas, obj, .{ .data = .{ .angle = next } }); } } }, .scale => |scale| { var next = scale; var changed = false; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "x:", .{}, .{}); const T = @TypeOf(next.scale_x); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.scale_x, .min = @as(T, 0.0), .max = @as(T, 10.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "y:", .{}, .{}); const T = @TypeOf(next.scale_y); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.scale_y, .min = @as(T, 0.0), .max = @as(T, 10.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } if (changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .scale = next } }); } }, .visible => |v| { var next = v; if (dvui.checkbox(@src(), &next, "Visible", .{})) { applyPropertyPatch(canvas, obj, .{ .data = .{ .visible = next } }); } }, .opacity => |opacity| { var next = opacity; if (dvui.sliderEntry(@src(), "{d:0.2}", .{ .value = &next, .min = 0.0, .max = 1.0, .interval = 0.01 }, .{ .expand = .horizontal })) { applyPropertyPatch(canvas, obj, .{ .data = .{ .opacity = next } }); } }, .locked => |v| { var next = v; if (dvui.checkbox(@src(), &next, "Locked", .{})) { applyPropertyPatch(canvas, obj, .{ .data = .{ .locked = next } }); } }, .size => |size| { var next = size; var changed = false; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "w:", .{}, .{}); const T = @TypeOf(next.w); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.w, .min = @as(T, 0.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "h:", .{}, .{}); const T = @TypeOf(next.h); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.h, .min = @as(T, 0.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } if (changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .size = next } }); } }, .radii => |radii| { var next = radii; var changed = false; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "x:", .{}, .{}); const T = @TypeOf(next.x); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x, .min = @as(T, 0.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "y:", .{}, .{}); const T = @TypeOf(next.y); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y, .min = @as(T, 0.0) }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } if (changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .radii = next } }); } }, .arc_percent => |pct| { var next = pct; if (dvui.sliderEntry(@src(), "{d:0.0}%", .{ .value = &next, .min = 0.0, .max = 100.0, .interval = 1.0 }, .{ .expand = .horizontal })) { applyPropertyPatch(canvas, obj, .{ .data = .{ .arc_percent = next } }); } }, .end_point => |pt| { var next = pt; var changed = false; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "x:", .{}, .{}); const T = @TypeOf(next.x); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "y:", .{}, .{}); const T = @TypeOf(next.y); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y }, .{ .expand = .horizontal }); subrow.deinit(); changed = res.changed or changed; } if (changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .end_point = next } }); } }, .points => |points| { var list = std.ArrayList(Point2_f).empty; list.appendSlice(canvas.allocator, points) catch { dvui.label(@src(), "Points: {d}", .{points.len}, .{}); return; }; defer list.deinit(canvas.allocator); dvui.label(@src(), "Points: {d}", .{list.items.len}, .{}); var changed = false; var to_delete: ?usize = null; for (list.items, 0..) |*pt, i| { // Одна строка: крестик удаления + paned с X/Y пополам var subrow = dvui.box( @src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = i, }, ); { // Крестик удаления if (dvui.buttonIcon(@src(), "Delete", icons.cross, .{}, .{}, .{ .id_extra = i, .gravity_y = 0.5, .margin = .{ .x = 8, }, })) { to_delete = i; } // Панель с X и Y, разделёнными пополам var split_ratio: f32 = 0.5; var paned = dvui.paned( @src(), .{ .direction = .horizontal, .collapsed_size = 0.0, .split_ratio = &split_ratio, .handle_size = 0, }, .{ .expand = .horizontal, }, ); { if (paned.showFirst()) { var x_box = dvui.box( @src(), .{ .dir = .horizontal }, .{ .expand = .both }, ); { dvui.labelNoFmt(@src(), "x:", .{}, .{ .gravity_y = 0.5, }); const Tx = @TypeOf(pt.x); const res_x = dvui.textEntryNumber( @src(), Tx, .{ .value = &pt.x }, .{ .expand = .horizontal }, ); changed = res_x.changed or changed; } x_box.deinit(); } if (paned.showSecond()) { var y_box = dvui.box( @src(), .{ .dir = .horizontal }, .{ .expand = .both }, ); { dvui.labelNoFmt(@src(), "y:", .{}, .{ .gravity_y = 0.5, }); const Ty = @TypeOf(pt.y); const res_y = dvui.textEntryNumber( @src(), Ty, .{ .value = &pt.y }, .{ .expand = .horizontal }, ); changed = res_y.changed or changed; } y_box.deinit(); } } paned.deinit(); } subrow.deinit(); } // Удаление выбранной точки if (to_delete) |idx| { _ = list.orderedRemove(idx); changed = true; } // Кнопка добавления новой точки (одна на весь список) if (dvui.button(@src(), "Add point", .{}, .{})) { const T = @TypeOf(list.items[0]); const new_point: T = if (list.items.len > 0) list.items[list.items.len - 1] else .{ .x = 0, .y = 0 }; list.append(canvas.allocator, new_point) catch {}; changed = true; } if (changed) { const slice = canvas.allocator.dupe(Point2_f, list.items) catch return; obj.setProperty(canvas.allocator, .{ .data = .{ .points = slice } }) catch { canvas.allocator.free(slice); return; }; canvas.requestRedraw(); } }, .fill_rgba => |rgba| { drawColorEditor(canvas, obj, rgba, true); }, .stroke_rgba => |rgba| { drawColorEditor(canvas, obj, rgba, false); }, .thickness => |t| { var next = t; { var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.labelNoFmt(@src(), "thickness:", .{}, .{}); const T = @TypeOf(next); const res = dvui.textEntryNumber(@src(), T, .{ .value = &next, .min = @as(T, 0.0), .max = @as(T, 100.0) }, .{ .expand = .horizontal }); subrow.deinit(); if (res.changed) { applyPropertyPatch(canvas, obj, .{ .data = .{ .thickness = next } }); } } }, .closed => |v| { var next = v; if (dvui.checkbox(@src(), &next, "Closed", .{})) { applyPropertyPatch(canvas, obj, .{ .data = .{ .closed = next } }); } }, .filled => |v| { var next = v; if (dvui.checkbox(@src(), &next, "Filled", .{})) { applyPropertyPatch(canvas, obj, .{ .data = .{ .filled = next } }); } }, } } row.deinit(); } fn drawColorEditor(canvas: *Canvas, obj: *Document.Object, rgba: u32, is_fill: bool) void { var hsv = dvui.Color.HSV.fromColor(rgbaToColor(rgba)); if (dvui.colorPicker( @src(), .{ .hsv = &hsv, .dir = .horizontal, .sliders = .rgb, .alpha = true, .hex_text_entry = true }, .{ .expand = .horizontal }, )) { const next = colorToRgba(hsv.toColor()); const patch: Property = if (is_fill) .{ .data = .{ .fill_rgba = next } } else .{ .data = .{ .stroke_rgba = next } }; applyPropertyPatch(canvas, obj, patch); } } fn propertyLabel(tag: std.meta.Tag(PropertyData)) []const u8 { return switch (tag) { .position => "Position", .angle => "Angle", .scale => "Scale", .visible => "Visible", .opacity => "Opacity", .locked => "Locked", .size => "Size", .radii => "Radii", .arc_percent => "Arc %", .end_point => "End point", .points => "Points", .fill_rgba => "Fill color", .stroke_rgba => "Stroke color", .thickness => "Thickness", .closed => "Closed", .filled => "Filled", }; } fn rgbaToColor(rgba: u32) dvui.Color { return .{ .r = @intCast((rgba >> 24) & 0xFF), .g = @intCast((rgba >> 16) & 0xFF), .b = @intCast((rgba >> 8) & 0xFF), .a = @intCast((rgba >> 0) & 0xFF), }; } fn colorToRgba(color: dvui.Color) u32 { return (@as(u32, color.r) << 24) | (@as(u32, color.g) << 16) | (@as(u32, color.b) << 8) | (@as(u32, color.a) << 0); }