const std = @import("std"); const rl = @import("raylib"); const srcery = @import("./srcery.zig"); const Assets = @import("./assets.zig"); const rect_utils = @import("./rect-utils.zig"); const utils = @import("./utils.zig"); const builtin = @import("builtin"); const FontFace = @import("./font-face.zig"); const Platform = @import("./platform/root.zig"); const raylib_h = @cImport({ @cInclude("stdio.h"); @cInclude("raylib.h"); }); const assert = std.debug.assert; pub const Vec2 = rl.Vector2; pub const Rect = rl.Rectangle; const clamp = std.math.clamp; const log = std.log.scoped(.ui); const Vec2Zero = Vec2{ .x = 0, .y = 0 }; const UI = @This(); const max_boxes = 512; const max_events = 256; const draw_debug = false; //builtin.mode == .Debug; const default_font = Assets.FontId{ .variant = .regular, .size = 16 }; pub const Key = struct { const StringHasher = std.hash.XxHash3; pub const CombineHasher = std.hash.Fnv1a_64; hash: u64 = 0, pub fn init(hash: u64) Key { return Key{ .hash = hash }; } pub fn initPtr(ptr: anytype) Key { return Key.initUsize(@intFromPtr(ptr)); } pub fn initUsize(num: usize) Key { return Key{ .hash = @truncate(num) }; } pub fn initString(seed: u64, text: []const u8) Key { return Key{ .hash = StringHasher.hash(seed, text) }; } pub fn combine(self: Key, other: Key) Key { var hasher = CombineHasher.init(); hasher.update(std.mem.asBytes(&self.hash)); hasher.update(std.mem.asBytes(&other.hash)); return Key{ .hash = hasher.final() }; } pub fn initNil() Key { return Key{ .hash = 0 }; } pub fn eql(self: Key, other: Key) bool { return self.hash == other.hash; } pub fn isNil(self: Key) bool { return self.hash == 0; } pub fn format( self: Key, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype, ) !void { _ = fmt; _ = options; try writer.print("{s}{{ 0x{x} }}", .{ @typeName(Key), self.hash }); } }; pub const Event = union(enum) { mouse_pressed: rl.MouseButton, mouse_released: rl.MouseButton, mouse_move: Vec2, mouse_scroll: Vec2, key_pressed: u32, key_released: u32, char_pressed: u32 }; pub const Signal = struct { pub const Flag = enum { left_pressed, middle_pressed, right_pressed, left_released, middle_released, right_released, left_clicked, middle_clicked, right_clicked, left_dragging, middle_dragging, right_dragging, scrolled }; pub const Flags = std.EnumSet(Flag); flags: Flags = .{}, drag: Vec2 = Vec2Zero, scroll: Vec2 = Vec2Zero, relative_mouse: Vec2 = Vec2Zero, mouse: Vec2 = Vec2Zero, is_mouse_inside: bool = false, hot: bool = false, active: bool = false, shift_modifier: bool = false, ctrl_modifier: bool = false, clicked_outside: bool = false, pub fn clicked(self: Signal) bool { return self.flags.contains(.left_clicked) or self.flags.contains(.right_clicked) or self.flags.contains(.middle_clicked); } pub fn dragged(self: Signal) bool { return self.flags.contains(.left_dragging) or self.flags.contains(.right_dragging) or self.flags.contains(.middle_dragging); } pub fn scrolled(self: Signal) bool { return self.flags.contains(.scrolled); } fn insertMousePressed(self: *Signal, mouse_button: rl.MouseButton) void { if (mouse_button == .mouse_button_left) { self.flags.insert(.left_pressed); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_pressed); } else if (mouse_button == .mouse_button_middle) { self.flags.insert(.middle_pressed); } } fn insertMouseReleased(self: *Signal, mouse_button: rl.MouseButton) void { if (mouse_button == .mouse_button_left) { self.flags.insert(.left_released); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_released); } else if (mouse_button == .mouse_button_middle) { self.flags.insert(.middle_released); } } fn insertMouseClicked(self: *Signal, mouse_button: rl.MouseButton) void { if (mouse_button == .mouse_button_left) { self.flags.insert(.left_clicked); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_clicked); } else if (mouse_button == .mouse_button_middle) { self.flags.insert(.middle_clicked); } } fn insertMouseDragged(self: *Signal, mouse_button: rl.MouseButton) void { if (mouse_button == .mouse_button_left) { self.flags.insert(.left_dragging); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_dragging); } else if (mouse_button == .mouse_button_middle) { self.flags.insert(.middle_dragging); } } }; pub const Axis = enum { X, Y, pub fn flip(self: Axis) Axis { return switch (self) { .X => .Y, .Y => .X }; } }; pub const Unit = union(enum) { pixels: f32, parent_percent: f32, text, font_size: f32, pub fn initPixels(pixels: f32) Unit { return Unit{ .pixels = pixels }; } pub fn initParentPct(percent: f32) Unit { return Unit{ .parent_percent = percent }; } fn get(self: Unit, box: *const Box, axis: Axis) f32 { switch (self) { .pixels => |pixels| { return pixels; }, .parent_percent => |pct| { const parent_box = box.parent() orelse return 0; return parent_box.availableChildrenSize(axis) * pct; }, .text => { const text = box.text orelse return 0; const font_face = Assets.font(box.font); const lines: []const []u8 = box.text_lines.constSlice(); var text_size: Vec2 = undefined; if (lines.len == 0) { text_size = font_face.measureText(text); } else { text_size = font_face.measureTextLines(lines); } return vec2ByAxis(&text_size, axis).*; }, .font_size => |count| { return box.font.size * count; } } } }; pub const Sizing = union(enum) { fixed: Unit, grow: struct { min: Unit, max: Unit }, shrink: struct { min: Unit, max: Unit }, fit_children, pub fn initFitChildren() Sizing { return Sizing{ .fit_children = {} }; } pub fn initFixed(size: Unit) Sizing { return Sizing{ .fixed = size }; } pub fn initFixedPixels(size: f32) Sizing { return Sizing{ .fixed = Unit.initPixels(size) }; } pub fn initFixedText() Sizing { return Sizing{ .fixed = .text }; } pub fn initGrowFull() Sizing { return initGrow( .{ .pixels = 0 }, .{ .parent_percent = 1 } ); } pub fn initGrowUpTo(size: Unit) Sizing { return initGrow( .{ .pixels = 0 }, size ); } pub fn initGrowFrom(size: Unit) Sizing { return initGrow( size, .{ .parent_percent = 1 } ); } pub fn initGrow(min: Unit, max: Unit) Sizing { return Sizing{ .grow = .{ .min = min, .max = max } }; } pub fn initShrinkFull() Sizing { return initShrink( .{ .pixels = 0 }, .{ .parent_percent = 1 } ); } pub fn initShrink(min: Unit, max: Unit) Sizing { return Sizing{ .shrink = .{ .min = min, .max = max } }; } pub fn dependsOnParent(self: Sizing) bool { return switch (self) { .fixed => |fixed| fixed == .parent_percent, .grow => |range| range.min == .parent_percent or range.max == .parent_percent, .shrink => |range| range.min == .parent_percent or range.max == .parent_percent, .fit_children => false }; } }; pub const Sizing2 = struct { x: Sizing, y: Sizing, pub fn getAxis(self: Sizing2, axis: Axis) Sizing { return switch (axis) { .X => self.x, .Y => self.y, }; } pub fn getAxisPtr(self: *Sizing2, axis: Axis) *Sizing { return switch (axis) { .X => &self.x, .Y => &self.y, }; } }; pub const LayoutDirection = enum { left_to_right, top_to_bottom, // TODO: right_to_left // TODO: bottom_to_top pub fn isAxis(self: LayoutDirection, axis: Axis) bool { return switch (self) { .left_to_right => (axis == .X), .top_to_bottom => (axis == .Y) }; } }; // TODO: Use `Size` instead of f32 in `Padding` // Not sure if it is worth implementing this now. This will really complicate layout because of .parent_percent pub const Padding = struct { top : f32 = 0, bottom: f32 = 0, left : f32 = 0, right : f32 = 0, pub fn all(size: f32) Padding { return Padding{ .left = size, .right = size, .top = size, .bottom = size }; } pub fn vertical(size: f32) Padding { return Padding{ .top = size, .bottom = size }; } pub fn horizontal(size: f32) Padding { return Padding{ .left = size, .right = size }; } pub fn byAxis(self: Padding, axis: Axis) f32 { return switch (axis) { .X => self.left + self.right, .Y => self.top + self.bottom }; } }; pub const Alignment = enum { start, center, end, pub fn getCoefficient(self: Alignment) f32 { return switch (self) { .start => 0, .center => 0.5, .end => 1 }; } }; pub const Alignment2 = struct { x: Alignment, y: Alignment, pub fn getAxis(self: Alignment2, axis: Axis) Alignment { return switch (axis) { .X => self.x, .Y => self.y, }; } }; pub const Border = struct { size: f32 = 0, color: rl.Color = rl.Color.magenta, }; pub const Borders = struct { left: Border = .{}, right: Border = .{}, top: Border = .{}, bottom: Border = .{}, pub fn all(border: Border) Borders { return Borders{ .left = border, .right = border, .top = border, .bottom = border, }; } pub fn vertical(border: Border) Borders { return Borders{ .top = border, .bottom = border, }; } pub fn horizontal(border: Border) Borders { return Borders{ .left = border, .right = border, }; } pub fn bottom(border: Border) Borders { return Borders{ .bottom = border, }; } }; const BoxIndex = std.math.IntFittingRange(0, max_boxes); pub const Box = struct { pub const Persistent = struct { position: Vec2 = Vec2Zero, size: Vec2 = Vec2Zero, sroll_offset: f32 = 0, hot: f32 = 0, active: f32 = 0, }; pub const Flag = enum { wrap_text, float_x, float_y, clickable, scrollable, draggable, draw_hot, draw_active, clip_view }; pub const Flags = std.EnumSet(Flag); pub const Draw = struct { ctx: ?*anyopaque = null, do: *const fn(ctx: ?*anyopaque, box: *Box) void }; const max_wrapped_lines = 64; ui: *UI, allocator: std.mem.Allocator, created: bool = false, flags: Flags, background: ?rl.Color, layout_direction: LayoutDirection, // TODO: Use `Size` instead of f32 in `layout_gap`. Not sure if this is needed at the moment. layout_gap: f32, persistent: Persistent, alignment: Alignment2, size: Sizing2, // TODO: Add option for changing how padding interacts with size. Does it remove from the available size, or is it added on top // Currently it is added on top padding: Padding, font: Assets.FontId, text_color: rl.Color, // TODO: Add option to specify where the border is drawn: outside, inside, center. borders: Borders, text: ?[]u8, hot_cursor: ?rl.MouseCursor, active_cursor: ?rl.MouseCursor, view_offset: Vec2 = Vec2Zero, texture: ?rl.Texture2D = null, texture_size: ?Vec2 = null, texture_color: ?rl.Color = null, scientific_number: ?f64 = null, scientific_precision: u32 = 1, float_relative_to: ?*Box = null, draw: ?Draw = null, visual_hot: bool = false, visual_active: bool = false, tooltip: ?[]const u8 = null, float_x: ?f32 = null, float_y: ?f32 = null, // Variables that you probably shouldn't be touching last_used_frame: u64 = 0, key: Key = Key.initNil(), text_lines: std.BoundedArray([]u8, max_wrapped_lines) = .{ }, // Fields for maintaining tree data structure tree: struct { // Index of this box index: BoxIndex, // Go down the tree to the first child first_child_index: ?BoxIndex = null, // Go down the tree to the last child last_child_index: ?BoxIndex = null, // Go up the tree parent_index: ?BoxIndex = null, // Go the next node on the same level next_sibling_index: ?BoxIndex = null, // Go to previous node on the same level prev_sibling_index: ?BoxIndex = null, }, pub fn beginChildren(self: *Box) void { self.ui.parent_stack.appendAssumeCapacity(self.tree.index); } pub fn endChildren(self: *Box) void { const index = self.ui.parent_stack.pop(); assert(self.tree.index == index); } pub fn iterChildren(self: *const Box) BoxChildIterator { return BoxChildIterator{ .boxes = self.ui.boxes.slice(), .current_child = self.tree.first_child_index }; } pub fn iterParents(self: *const Box) BoxParentIterator { return BoxParentIterator{ .boxes = self.ui.boxes.slice(), .current_parent = self.tree.parent_index }; } pub fn rect(self: *const Box) Rect { return Rect{ .x = self.persistent.position.x, .y = self.persistent.position.y, .width = self.persistent.size.x, .height = self.persistent.size.y, }; } pub fn parent(self: *const Box) ?*Box { if (self.tree.parent_index) |parent_index| { return self.ui.getBoxByIndex(parent_index); } else { return null; } } pub fn setText(self: *Box, text: []const u8) void { self.text = self.allocator.dupe(u8, text) catch return; self.text_lines.len = 0; } pub fn setFmtText(self: *Box, comptime fmt: []const u8, args: anytype) void { self.text = std.fmt.allocPrint(self.allocator, fmt, args) catch return; self.text_lines.len = 0; } pub fn setFloatX(self: *Box, x: f32) void { self.float_x = x; } pub fn setFloatY(self: *Box, y: f32) void { self.float_y = y; } pub fn setFloatRect(self: *Box, float_rect: Rect) void { self.setFloatX(float_rect.x); self.setFloatY(float_rect.y); self.size.x = .{ .fixed = .{ .pixels = float_rect.width } }; self.size.y = .{ .fixed = .{ .pixels = float_rect.height } }; } pub fn wrapText(self: *Box, width: f32) void { self.text_lines.len = 0; const text = self.text orelse return; const font_face = Assets.font(self.font); var line_start: usize = 0; var prev_word_end: usize = 0; var word_iter = std.mem.tokenizeScalar(u8, text, ' '); while (true) { _ = word_iter.peek(); const word_start = word_iter.index; if (word_iter.next() == null) break; const word_end = word_iter.index; const line = text[line_start..word_end]; const line_size = font_face.measureText(line); if (line_size.x > width) { const prev_line = text[line_start..prev_word_end]; self.text_lines.append(prev_line) catch return; line_start = word_start; } prev_word_end = word_end; } if (line_start < text.len) { self.text_lines.append(text[line_start..prev_word_end]) catch return; } } fn isFloating(self: *const Box, axis: Axis) bool { return switch (axis) { .X => self.float_x != null, .Y => self.float_y != null, }; } fn overflowEnabled(self: *const Box, axis: Axis) bool { return switch (axis) { .X => self.flags.contains(.overflow_x), .Y => self.flags.contains(.overflow_y), }; } fn availableChildrenSize(self: *Box, axis: Axis) f32 { const size = vec2ByAxis(&self.persistent.size, axis).*; return @max(size - self.padding.byAxis(axis), 0); } pub fn appendChild(self: *Box, child: *Box) void { child.tree.parent_index = self.tree.index; if (self.tree.last_child_index) |last_child_index| { const last_child = self.ui.getBoxByIndex(last_child_index); last_child.tree.next_sibling_index = child.tree.index; self.tree.last_child_index = child.tree.index; self.tree.prev_sibling_index = last_child.tree.index; } else { self.tree.first_child_index = child.tree.index; self.tree.last_child_index = child.tree.index; } } pub fn removeChild(self: *Box, child: *Box) void { assert(child.tree.parent_index == self.tree.index); child.tree.parent_index = null; defer child.tree.next_sibling_index = null; defer child.tree.prev_sibling_index = null; var next_sibling: ?*Box = null; if (child.tree.next_sibling_index) |next_sibling_index| { next_sibling = self.ui.getBoxByIndex(next_sibling_index); } var prev_sibling: ?*Box = null; if (child.tree.prev_sibling_index) |prev_sibling_index| { prev_sibling = self.ui.getBoxByIndex(prev_sibling_index); } if (next_sibling != null and prev_sibling != null) { next_sibling.?.tree.prev_sibling_index = prev_sibling.?.tree.index; prev_sibling.?.tree.next_sibling_index = next_sibling.?.tree.index; } else if (next_sibling != null) { next_sibling.?.tree.prev_sibling_index = null; self.tree.first_child_index = next_sibling.?.tree.index; } else if (prev_sibling != null) { prev_sibling.?.tree.next_sibling_index = null; self.tree.last_child_index = prev_sibling.?.tree.index; } } fn hasChildren(self: *const Box) bool { return self.tree.first_child_index != null; } pub fn bringChildToTop(self: *Box, child: *Box) void { self.removeChild(child); self.appendChild(child); } }; pub const BoxOptions = struct { key: ?Key = null, size_x: ?Sizing = null, size_y: ?Sizing = null, background: ?rl.Color = null, layout_direction: ?LayoutDirection = null, layout_gap: ?f32 = null, padding: ?Padding = null, align_x: ?Alignment = null, align_y: ?Alignment = null, text: ?[]const u8 = null, font: ?Assets.FontId = null, text_color: ?rl.Color = null, flags: ?[]const Box.Flag = null, position_x: ?f32 = null, position_y: ?f32 = null, borders: ?Borders = null, hot_cursor: ?rl.MouseCursor = null, active_cursor: ?rl.MouseCursor = null, view_offset: ?Vec2 = null, texture: ?rl.Texture2D = null, texture_size: ?Vec2 = null, float_rect: ?Rect = null, scientific_number: ?f64 = null, scientific_precision: ?u32 = null, float_relative_to: ?*Box = null, parent: ?*UI.Box = null, texture_color: ?rl.Color = null, draw: ?Box.Draw = null, visual_hot: ?bool = null, visual_active: ?bool = null }; pub const root_box_key = Key.initString(0, "$root$"); pub const mouse_tooltip_box_key = Key.initString(0, "$mouse_tooltip$"); const BoxChildIterator = struct { current_child: ?BoxIndex, boxes: []Box, pub fn next(self: *BoxChildIterator) ?*Box { if (self.current_child) |child_index| { const child = &self.boxes[child_index]; self.current_child = child.tree.next_sibling_index; return child; } else { return null; } } }; const BoxParentIterator = struct { current_parent: ?BoxIndex, boxes: []Box, pub fn next(self: *BoxParentIterator) ?*Box { if (self.current_parent) |parent_index| { const parent = &self.boxes[parent_index]; self.current_parent = parent.tree.parent_index; return parent; } else { return null; } } }; const CharPressIterator = struct { events: *Events, index: usize = 0, pub fn next(self: *CharPressIterator) ?u32 { while (self.index < self.events.len) { const event = self.events.get(self.index); self.index += 1; if (event == .char_pressed) { return event.char_pressed; } } return null; } pub fn consume(self: *CharPressIterator) void { self.index -= 1; _ = self.events.swapRemove(self.index); } }; const Events = std.BoundedArray(Event, max_events); arenas: [2]std.heap.ArenaAllocator, // Retained structures. Used for tracking changes between frames hot_box_key: ?Key = null, active_box_keys: std.EnumMap(rl.MouseButton, Key) = .{}, frame_index: u64 = 0, // Per frame fields scissor_stack: std.BoundedArray(Rect, 16) = .{}, mouse: Vec2 = .{ .x = 0, .y = 0 }, mouse_delta: Vec2 = .{ .x = 0, .y = 0 }, mouse_buttons: std.EnumSet(rl.MouseButton) = .{}, events: Events = .{}, dt: f32 = 0, // Per layout pass fields font_stack: std.BoundedArray(Assets.FontId, 16) = .{}, boxes: std.BoundedArray(Box, max_boxes) = .{}, parent_stack: std.BoundedArray(BoxIndex, max_boxes) = .{}, pub fn init(allocator: std.mem.Allocator) UI { return UI{ .arenas = .{ std.heap.ArenaAllocator.init(allocator), std.heap.ArenaAllocator.init(allocator) }, }; } pub fn deinit(self: *UI) void { self.arenas[0].deinit(); self.arenas[1].deinit(); } pub fn frameArena(self: *UI) *std.heap.ArenaAllocator { return &self.arenas[@mod(self.frame_index, 2)]; } pub fn frameAllocator(self: *UI) std.mem.Allocator { return self.frameArena().allocator(); } pub fn pullOsEvents(self: *UI) void { self.events.len = 0; const mouse = rl.getMousePosition(); self.mouse_delta = mouse.subtract(self.mouse); self.dt = rl.getFrameTime(); self.mouse = mouse; self.mouse_buttons = .{}; inline for (std.meta.fields(rl.MouseButton)) |mouse_button_field| { const mouse_button: rl.MouseButton = @enumFromInt(mouse_button_field.value); if (rl.isMouseButtonPressed(mouse_button)) { self.events.appendAssumeCapacity(Event{ .mouse_pressed = mouse_button }); } if (rl.isMouseButtonReleased(mouse_button)) { self.events.appendAssumeCapacity(Event{ .mouse_released = mouse_button }); } if (rl.isMouseButtonDown(mouse_button)) { self.mouse_buttons.insert(mouse_button); } } for (0..512) |key| { if (raylib_h.IsKeyPressed(@intCast(key))) { self.events.appendAssumeCapacity(Event{ .key_pressed = @intCast(key) }); } if (raylib_h.IsKeyReleased(@intCast(key))) { self.events.appendAssumeCapacity(Event{ .key_released = @intCast(key) }); } } while (true) { const char = rl.getCharPressed(); if (char == 0) { break; } self.events.appendAssumeCapacity(Event{ .char_pressed = @intCast(char) }); } const mouse_wheel = rl.getMouseWheelMoveV(); if (mouse_wheel.x != 0 or mouse_wheel.y != 0) { self.events.appendAssumeCapacity(Event{ .mouse_scroll = mouse_wheel }); } } pub fn begin(self: *UI) void { self.font_stack.len = 0; self.parent_stack.len = 0; // Remove boxes which haven't been used in the last frame { var i: usize = 0; while (i < self.boxes.len) { const box = &self.boxes.buffer[i]; if (box.last_used_frame != self.frame_index) { if (self.boxes.len > 0) { self.boxes.buffer[i] = self.boxes.buffer[self.boxes.len - 1]; } self.boxes.len -= 1; } else { i += 1; } } } if (!self.isKeyActiveAny()) { self.hot_box_key = null; } self.frame_index += 1; _ = self.frameArena().reset(.retain_capacity); self.pushFont(default_font); _ = self.createBox(.{ .key = mouse_tooltip_box_key, .size_x = Sizing.initFitChildren(), .size_y = Sizing.initFitChildren(), .padding = Padding.all(8), .background = srcery.black, .borders = Borders.all(.{ .color = srcery.hard_black, .size = 4 }) }); const root_box = self.createBox(.{ .key = root_box_key, .size_x = .{ .fixed = .{ .pixels = @floatFromInt(rl.getScreenWidth()) } }, .size_y = .{ .fixed = .{ .pixels = @floatFromInt(rl.getScreenHeight()) } }, }); root_box.beginChildren(); } pub fn end(self: *UI) void { const mouse_tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; // Add mouse tooltip to hot item if (!mouse_tooltip.hasChildren() and self.hot_box_key != null) { const box = self.getBoxByKey(self.hot_box_key.?).?; if (box.tooltip) |tooltip| { mouse_tooltip.beginChildren(); defer mouse_tooltip.endChildren(); _ = self.createBox(.{ .size_x = Sizing.initFixed(.text), .size_y = Sizing.initFixed(.text), .text = tooltip }); } } const root_box = self.parentBox().?; root_box.endChildren(); self.popFont(); // Update Animations { const fast_rate = 1 - std.math.pow(f32, 2, (-50 * self.dt)); for (self.boxes.slice()) |*_box| { const box: *Box = _box; if (box.key.isNil()) continue; const is_hot = self.isKeyHot(box.key) or box.visual_hot; const is_active = self.isKeyActive(box.key) or box.visual_active; const is_hot_f32: f32 = @floatFromInt(@intFromBool(is_hot)); const is_active_f32: f32 = @floatFromInt(@intFromBool(is_active)); box.persistent.hot += fast_rate * (is_hot_f32 - box.persistent.hot ); box.persistent.active += fast_rate * (is_active_f32 - box.persistent.active); } } // Mouse cursor { var cursor: ?rl.MouseCursor = null; var active_iter = self.active_box_keys.iterator(); while (active_iter.next()) |active_box_key| { if (self.getBoxByKey(active_box_key.value.*)) |active_box| { cursor = active_box.active_cursor; if (cursor != null) { break; } } } if (cursor == null) { if (self.hot_box_key) |hot_box_key| { if (self.getBoxByKey(hot_box_key)) |hot_box| { cursor = hot_box.hot_cursor; } } } rl.setMouseCursor(@intFromEnum(cursor orelse rl.MouseCursor.mouse_cursor_default)); } // Reset sizes and positions, because it will be recalculated in layout pass for (self.boxes.slice()) |*box| { var position = Vec2{ .x = 0, .y = 0 }; if (box.float_x) |x| { position.x = x; } if (box.float_y) |y| { position.y = y; } box.persistent.size = Vec2Zero; box.persistent.position = position; } self.layoutPass(root_box); self.layoutSizesPass(mouse_tooltip); // Position mouse tooltip so it does not go off screen { const window_rect = rect_utils.shrink(Rect{ .x = 0, .y = 0, .width = @floatFromInt(rl.getScreenWidth()), .height = @floatFromInt(rl.getScreenHeight()) }, 16); const cursor_width = 12; var tooltip_rect = Rect{ .x = self.mouse.x + cursor_width, .y = self.mouse.y, .width = mouse_tooltip.persistent.size.x, .height = mouse_tooltip.persistent.size.y }; tooltip_rect.x += @max(0, rect_utils.left(window_rect) - rect_utils.left(tooltip_rect)); tooltip_rect.x -= @max(0, rect_utils.right(tooltip_rect) - rect_utils.right(window_rect)); tooltip_rect.y += @max(0, rect_utils.top(window_rect) - rect_utils.top(tooltip_rect)); tooltip_rect.y -= @max(0, rect_utils.bottom(tooltip_rect) - rect_utils.bottom(window_rect)); mouse_tooltip.persistent.position = .{ .x = tooltip_rect.x, .y = tooltip_rect.y }; } self.layoutPositionsPass(mouse_tooltip); } fn layoutSizesPass(self: *UI, root_box: *Box) void { // TODO: Figure out a way for fit children to work without calling it twice. self.layoutSizesInitial(root_box, .X); self.layoutSizesShrink(root_box, .X); self.layoutSizesFitChildren(root_box, .X); self.layoutSizesGrow(root_box, .X); self.layoutSizesFitChildren(root_box, .X); self.layoutWrapText(root_box); self.layoutSizesInitial(root_box, .Y); self.layoutSizesShrink(root_box, .Y); self.layoutSizesFitChildren(root_box, .Y); self.layoutSizesGrow(root_box, .Y); self.layoutSizesFitChildren(root_box, .Y); } fn layoutPositionsPass(self: *UI, root_box: *Box) void { self.layoutPositions(root_box, .X); self.layoutPositions(root_box, .Y); self.layoutFloatingPositions(root_box, .X); self.layoutFloatingPositions(root_box, .Y); } fn layoutPass(self: *UI, root_box: *Box) void { self.layoutSizesPass(root_box); self.layoutPositionsPass(root_box); } pub fn pushFont(self: *UI, font_id: Assets.FontId) void { self.font_stack.appendAssumeCapacity(font_id); } pub fn popFont(self: *UI) void { _ = self.font_stack.pop(); } pub fn currentDefaultFont(self: *UI) Assets.FontId { assert(self.font_stack.len > 0); return self.font_stack.buffer[self.font_stack.len - 1]; } pub fn rem(self: *UI, count: f32) f32 { const font_id = self.currentDefaultFont(); return font_id.size * count; } inline fn vec2ByAxis(vec2: *Vec2, axis: Axis) *f32 { return switch (axis) { .X => &vec2.x, .Y => &vec2.y }; } fn layoutWrapText(self: *UI, box: *Box) void { if (box.flags.contains(.wrap_text)) { box.wrapText(box.availableChildrenSize(.X)); } { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutWrapText(child); } } } fn layoutSizesInitial(self: *UI, box: *Box, axis: Axis) void { const axis_persistent_size = vec2ByAxis(&box.persistent.size, axis); const sizing = box.size.getAxis(axis); if (sizing == .fixed) { axis_persistent_size.* = sizing.fixed.get(box, axis); } else if (sizing == .shrink) { axis_persistent_size.* = sizing.shrink.max.get(box, axis); } else if (sizing == .grow) { axis_persistent_size.* = sizing.grow.min.get(box, axis); } axis_persistent_size.* += box.padding.byAxis(axis); { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutSizesInitial(child, axis); } } } fn sumChildrenSize(box: *Box, axis: Axis) f32 { var axis_size: f32 = 0; if (box.layout_direction.isAxis(axis)) { var children_count: f32 = 0; var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const axis_child_size = vec2ByAxis(&child.persistent.size, axis); axis_size += axis_child_size.*; children_count += 1; } if (children_count > 0) { axis_size += box.layout_gap * (children_count - 1); } } else { var max_child_size: f32 = 0; var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const axis_child_size = vec2ByAxis(&child.persistent.size, axis); max_child_size = @max(max_child_size, axis_child_size.*); } axis_size += max_child_size; } return axis_size; } fn layoutSizesShrink(self: *UI, box: *Box, axis: Axis) void { { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutSizesShrink(child, axis); } } if (box.layout_direction.isAxis(axis)) { const available_children_size = box.availableChildrenSize(axis); const used_axis_size = sumChildrenSize(box, axis); var overflow_size = used_axis_size - available_children_size; if (overflow_size > 0) { const ShrinkableChild = struct { box: *Box, min_size: f32 }; var shrinkable_children: std.BoundedArray(ShrinkableChild, max_boxes) = .{}; var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const child_axis_sizing = child.size.getAxis(axis); if (child_axis_sizing == .shrink) { shrinkable_children.appendAssumeCapacity(.{ .box = child, .min_size = child_axis_sizing.shrink.min.get(child, axis) }); } } while (overflow_size > 0) { // Remove children that have reached minimum size { var i: usize = 0; while (i < shrinkable_children.len) { const shrinkable_child = shrinkable_children.get(i); const child = shrinkable_child.box; const child_min_size = shrinkable_child.min_size; const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); if (child_persistent_size.* <= child_min_size) { _ = shrinkable_children.swapRemove(i); continue; } else { i += 1; } } } if (shrinkable_children.len == 0) { break; } var largest_child = shrinkable_children.buffer[0]; var largest_size: f32 = vec2ByAxis(&largest_child.box.persistent.size, axis).*; var second_largest_size: ?f32 = null; var largest_children: std.BoundedArray(*Box, max_boxes) = .{}; largest_children.appendAssumeCapacity(largest_child.box); for (shrinkable_children.slice()[1..]) |shrinkable_child| { const child = shrinkable_child.box; const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); if (child_persistent_size.* > largest_size) { second_largest_size = largest_size; largest_size = child_persistent_size.*; largest_child = shrinkable_child; largest_children.len = 0; largest_children.appendAssumeCapacity(child); } else if (child_persistent_size.* < largest_size) { second_largest_size = @max(second_largest_size orelse 0, child_persistent_size.*); } else { largest_children.appendAssumeCapacity(child); } } var shrink_largest_by = @max(overflow_size, largest_child.min_size); if (second_largest_size != null) { shrink_largest_by = @min(shrink_largest_by, largest_size - second_largest_size.?); } const largest_count_f32: f32 = @floatFromInt(largest_children.len); shrink_largest_by = @min(shrink_largest_by * largest_count_f32, largest_size) / largest_count_f32; for (largest_children.slice()) |child| { const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); child_persistent_size.* -= shrink_largest_by; overflow_size -= shrink_largest_by; } } } } } fn layoutSizesFitChildren(self: *UI, box: *Box, axis: Axis) void { { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutSizesFitChildren(child, axis); } } if (box.size.getAxis(axis) == .fit_children) { const children_size = sumChildrenSize(box, axis); const persistent_size = vec2ByAxis(&box.persistent.size, axis); persistent_size.* = children_size + box.padding.byAxis(axis); } } fn layoutSizesGrow(self: *UI, box: *Box, axis: Axis) void { const available_children_size = box.availableChildrenSize(axis); // Hack to make .{ .fixed = { .parent_percent = 1} } work when parent is .grow // I can't be bother to find a nice solutino for this. // I need this for a scrollbar. const sizing = box.size.getAxis(axis); if (sizing.dependsOnParent() and sizing == .fixed) { const persistent_size = vec2ByAxis(&box.persistent.size, axis); persistent_size.* = sizing.fixed.get(box, axis); } if (box.layout_direction.isAxis(axis)) { const used_axis_size = sumChildrenSize(box, axis); var unused_size = available_children_size - used_axis_size; if (unused_size > 0) { const GrowableChild = struct { box: *Box, max_size: f32 }; var growable_children: std.BoundedArray(GrowableChild, max_boxes) = .{}; var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const child_axis_sizing = child.size.getAxis(axis); if (child_axis_sizing == .grow) { growable_children.appendAssumeCapacity(.{ .box = child, .max_size = child_axis_sizing.grow.max.get(child, axis) }); } } while (unused_size > 0) { // Remove children that have reached maximum size { var i: usize = 0; while (i < growable_children.len) { const growable_child = growable_children.get(i); const child = growable_child.box; const child_max_size = growable_child.max_size; const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); if (child_persistent_size.* >= child_max_size) { _ = growable_children.swapRemove(i); continue; } else { i += 1; } } } if (growable_children.len == 0) { break; } var smallest_child = growable_children.buffer[0]; var smallest_size: f32 = vec2ByAxis(&smallest_child.box.persistent.size, axis).*; var second_smallest_size: ?f32 = null; var smallest_children: std.BoundedArray(*Box, max_boxes) = .{}; smallest_children.appendAssumeCapacity(smallest_child.box); for (growable_children.slice()[1..]) |growable_child| { const child = growable_child.box; const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); if (child_persistent_size.* < smallest_size) { second_smallest_size = smallest_size; smallest_size = child_persistent_size.*; smallest_child = growable_child; smallest_children.len = 0; smallest_children.appendAssumeCapacity(child); } else if (child_persistent_size.* > smallest_size) { second_smallest_size = @min(second_smallest_size orelse std.math.inf(f32), child_persistent_size.*); } else { smallest_children.appendAssumeCapacity(child); } } var grow_smallest_by = @min(unused_size, smallest_child.max_size); if (second_smallest_size != null) { grow_smallest_by = @min(grow_smallest_by, second_smallest_size.? - smallest_size); } const smallest_count_f32: f32 = @floatFromInt(smallest_children.len); grow_smallest_by = @min(grow_smallest_by * smallest_count_f32, unused_size) / smallest_count_f32; for (smallest_children.slice()) |child| { const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); child_persistent_size.* += grow_smallest_by; unused_size -= grow_smallest_by; } } } } else { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const child_axis_sizing = child.size.getAxis(axis); if (child_axis_sizing == .grow) { const child_max_size = child_axis_sizing.grow.max.get(child, axis); const child_persistent_size = vec2ByAxis(&child.persistent.size, axis); child_persistent_size.* = @min(available_children_size, child_max_size); } } } { var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutSizesGrow(child, axis); } } } fn layoutPositions(self: *UI, box: *Box, axis: Axis) void { const is_layout_axis = box.layout_direction.isAxis(axis); { const available_children_size = box.availableChildrenSize(axis); const children_size = sumChildrenSize(box, axis); const axis_alignment = box.alignment.getAxis(axis); const alignment_offset_scalar = axis_alignment.getCoefficient(); var offset: f32 = 0; var child_iter = box.iterChildren(); while (child_iter.next()) |child| { if (child.isFloating(axis)) continue; const axis_position = vec2ByAxis(&box.persistent.position, axis); const axis_child_position = vec2ByAxis(&child.persistent.position, axis); const axis_child_persistent_size = vec2ByAxis(&child.persistent.size, axis); axis_child_position.* = axis_position.*; if (axis == .X) { axis_child_position.* += box.padding.left; } else if (axis == .Y) { axis_child_position.* += box.padding.top; } if (is_layout_axis) { axis_child_position.* += offset; offset += axis_child_persistent_size.*; offset += box.layout_gap; } axis_child_position.* += @max(available_children_size - children_size, 0) * alignment_offset_scalar; axis_child_position.* -= vec2ByAxis(&box.view_offset, axis).*; } } var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutPositions(child, axis); } } fn layoutFloatingPositions(self: *UI, box: *Box, axis: Axis) void { if (box.float_relative_to != null and box.isFloating(axis)) { const target = box.float_relative_to.?; const axis_position = vec2ByAxis(&box.persistent.position, axis); const axis_position_target = vec2ByAxis(&target.persistent.position, axis); axis_position.* += axis_position_target.*; } var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.layoutFloatingPositions(child, axis); } } pub fn createBox(self: *UI, opts: BoxOptions) *Box { var box: *Box = undefined; var box_index: ?BoxIndex = null; var key = opts.key orelse Key.initNil(); var persistent = Box.Persistent{}; var created = false; if (!key.isNil()) { if (self.getBoxIndexByKey(key)) |last_frame_box_index| { const last_frame_box = self.getBoxByIndex(last_frame_box_index); box = last_frame_box; persistent = last_frame_box.persistent; box_index = last_frame_box_index; // Safety guard for using a key multiple times in UI assert(box.last_used_frame != self.frame_index); } } if (box_index == null) { box = self.boxes.addOneAssumeCapacity(); box_index = self.boxes.len - 1; created = true; } var size = Sizing2{ .x = Sizing.initFixed(.{ .pixels = 0 }), .y = Sizing.initFixed(.{ .pixels = 0 }), }; if (opts.size_x) |size_x| { size.x = size_x; } else if (opts.text != null) { size.x = Sizing.initFixed(.text); } if (opts.size_y) |size_y| { size.y = size_y; } else if (opts.text != null) { size.y = Sizing.initFixed(.text); } var flags = Box.Flags.initEmpty(); if (opts.flags) |opts_flags| { for (opts_flags) |flag| { flags.insert(flag); } } const default_alignment = if (opts.text != null) Alignment.center else Alignment.start; const alignment = Alignment2{ .x = opts.align_x orelse default_alignment, .y = opts.align_y orelse default_alignment, }; box.* = Box{ .ui = self, .allocator = self.frameAllocator(), .created = created, .persistent = persistent, .flags = flags, .background = opts.background, .size = size, .layout_direction = opts.layout_direction orelse LayoutDirection.left_to_right, .layout_gap = opts.layout_gap orelse 0, .padding = opts.padding orelse Padding{}, .alignment = alignment, .font = opts.font orelse self.currentDefaultFont(), .text = null, .text_color = opts.text_color orelse srcery.bright_white, .borders = opts.borders orelse Borders{}, .hot_cursor = opts.hot_cursor, .active_cursor = opts.active_cursor, .view_offset = opts.view_offset orelse Vec2Zero, .texture = opts.texture, .texture_size = opts.texture_size, .scientific_number = opts.scientific_number, .scientific_precision = opts.scientific_precision orelse 1, .float_relative_to = opts.float_relative_to, .texture_color = opts.texture_color, .draw = opts.draw, .visual_hot = opts.visual_hot orelse false, .visual_active = opts.visual_active orelse false, .last_used_frame = self.frame_index, .key = key, .tree = .{ .index = box_index.? }, }; if (box.isFloating(.X)) { box.persistent.position.x = 0; } if (box.isFloating(.Y)) { box.persistent.position.y = 0; } if (opts.text) |text| { box.setText(text); } if (opts.position_x) |x| { box.setFloatX(x); } if (opts.position_y) |y| { box.setFloatY(y); } if (opts.float_rect) |rect| { box.setFloatRect(rect); } if (opts.parent orelse self.parentBox()) |parent| { parent.appendChild(box); } return box; } pub fn parentBoxIndex(self: *UI) ?BoxIndex { if (self.parent_stack.len > 0) { return self.parent_stack.buffer[self.parent_stack.len - 1]; } else { return null; } } pub fn parentBox(self: *UI) ?*Box { if (self.parentBoxIndex()) |box_index| { return self.getBoxByIndex(box_index); } else { return null; } } pub fn getBoxByIndex(self: *UI, box_index: BoxIndex) *Box { return &self.boxes.buffer[box_index]; } pub fn getBoxIndexByKey(self: *UI, key: Key) ?BoxIndex { for (0.., self.boxes.slice()) |index, box| { if (box.key.eql(key)) { return @intCast(index); } } return null; } pub fn getBoxByKey(self: *UI, key: Key) ?*Box { if (self.getBoxIndexByKey(key)) |box_index| { return self.getBoxByIndex(box_index); } else { return null; } } pub fn draw(self: *UI) void { defer assert(self.scissor_stack.len == 0); const root_box = self.getBoxByKey(root_box_key).?; const mouse_tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; self.drawBox(root_box); if (mouse_tooltip.hasChildren()) { self.drawBox(mouse_tooltip); } } fn drawBox(self: *UI, box: *Box) void { const box_rect = box.rect(); const do_scissor = box.flags.contains(.clip_view); if (do_scissor) self.beginScissor(box_rect); defer if (do_scissor) self.endScissor(); var value_shift: f32 = 0; if (box.flags.contains(.draw_active) and box.persistent.active > 0.01) { value_shift = -0.5 * box.persistent.active; } else if (box.flags.contains(.draw_hot) and box.persistent.hot > 0.01) { value_shift = 0.6 * box.persistent.hot; } if (box.background) |bg| { rl.drawRectangleRec(box_rect, utils.shiftColorInHSV(bg, value_shift)); } if (box.texture) |texture| { const source = rl.Rectangle{ .x = 0, .y = 0, .width = @floatFromInt(texture.width), .height = @floatFromInt(texture.height) }; var destination = box_rect; if (box.texture_size) |texture_size| { destination = rect_utils.initCentered(destination, texture_size.x, texture_size.y); } rl.drawTexturePro( texture, source, destination, rl.Vector2.zero(), 0, box.texture_color orelse rl.Color.white ); } const borders_with_coords = .{ .{ box.borders.left , rl.Vector2.init(0, 0), rl.Vector2.init(0, 1), rl.Vector2.init( 1, 0) }, .{ box.borders.right , rl.Vector2.init(1, 0), rl.Vector2.init(1, 1), rl.Vector2.init(-1, 0) }, .{ box.borders.top , rl.Vector2.init(0, 0), rl.Vector2.init(1, 0), rl.Vector2.init( 0, 1) }, .{ box.borders.bottom, rl.Vector2.init(0, 1), rl.Vector2.init(1, 1), rl.Vector2.init( 0, -1) } }; inline for (borders_with_coords) |border_with_coords| { const border = border_with_coords[0]; const line_from = border_with_coords[1]; const line_to = border_with_coords[2]; const inset_direction: rl.Vector2 = border_with_coords[3]; if (border.size > 0) { const inset = inset_direction.multiply(Vec2.init(border.size/2, border.size/2)); rl.drawLineEx( rect_utils.positionAt(box_rect, line_from).add(inset), rect_utils.positionAt(box_rect, line_to).add(inset), border.size, utils.shiftColorInHSV(border.color, value_shift) ); } } if (box.draw) |box_draw| { box_draw.do(box_draw.ctx, box); } const alignment_x_coeff = box.alignment.x.getCoefficient(); const alignment_y_coeff = box.alignment.y.getCoefficient(); if (box.text) |text| { const font_face = Assets.font(box.font); var text_position = box.persistent.position; text_position.x += box.padding.left; text_position.y += box.padding.top; const lines: [][]u8 = box.text_lines.slice(); const available_width = box.availableChildrenSize(.X); const available_height = box.availableChildrenSize(.Y); if (lines.len == 0) { const text_size = font_face.measureText(text); text_position.x += (available_width - text_size.x) * alignment_x_coeff; text_position.y += (available_height - text_size.y) * alignment_y_coeff; font_face.drawText(text, text_position, box.text_color); } else { // TODO: Don't call `measureTextLines`, // Because in the end `measureText` will be called twice for each line const text_size = font_face.measureTextLines(lines); text_position.x += (available_width - text_size.x) * alignment_x_coeff; text_position.y += (available_height - text_size.y) * alignment_y_coeff; var offset_y: f32 = 0; for (lines) |line| { const line_size = font_face.measureText(line); const offset_x = (text_size.x - line_size.x) * alignment_x_coeff; font_face.drawText( line, text_position.add(.{ .x = offset_x, .y = offset_y }), box.text_color ); offset_y += font_face.getSize() * font_face.line_height; } } } if (box.scientific_number) |scientific_number| { const regular = Assets.font(box.font); const superscript = Assets.font(.{ .size = box.font.size * 0.8, .variant = box.font.variant }); var text_position = box.persistent.position; text_position.x += box.padding.left; text_position.y += box.padding.top; const available_width = box.availableChildrenSize(.X); const available_height = box.availableChildrenSize(.Y); const exponent = @floor(std.math.log10(scientific_number)); const multiplier = std.math.pow(f64, 10, exponent); const coefficient = scientific_number / multiplier; // const text = std.fmt.allocPrint(box.allocator, "{d:.2}x10{d}", .{coefficient, exponent}) catch ""; var coefficient_buff: [256]u8 = undefined; const coefficient_str = std.fmt.formatFloat(&coefficient_buff, coefficient, .{ .mode = .decimal, .precision = box.scientific_precision }) catch ""; const exponent_str = std.fmt.allocPrint(box.allocator, "{d:.0}", .{exponent}) catch ""; var text_size = regular.measureText(coefficient_str); text_size.x += regular.measureWidth("x10"); text_size.x += superscript.measureWidth(exponent_str); text_position.x += (available_width - text_size.x) * alignment_x_coeff; text_position.y += (available_height - text_size.y) * alignment_y_coeff; var ctx = FontFace.DrawTextContext{ .font_face = regular, .origin = text_position, .tint = box.text_color }; ctx.drawText(coefficient_str); ctx.advanceY(-0.04); ctx.advanceX(0.1); ctx.drawText("x"); ctx.advanceY(0.04); ctx.drawText("10"); ctx.font_face = superscript; ctx.advanceY(-0.2); ctx.drawText(exponent_str); } if (draw_debug) { if (self.isKeyActive(box.key)) { rl.drawRectangleLinesEx(box_rect, 3, rl.Color.red); } else if (self.isKeyHot(box.key)) { rl.drawRectangleLinesEx(box_rect, 3, rl.Color.orange); } else { rl.drawRectangleLinesEx(box_rect, 1, rl.Color.magenta); } } var child_iter = box.iterChildren(); while (child_iter.next()) |child| { self.drawBox(child); } } fn beginScissor(self: *UI, rect: Rect) void { var intersected_rect = rect; if (self.scissor_stack.len > 0) { const top_scissor_rect = self.scissor_stack.buffer[self.scissor_stack.len - 1]; intersected_rect = rect_utils.intersect(top_scissor_rect, rect); } self.scissor_stack.appendAssumeCapacity(intersected_rect); rl.beginScissorMode( @intFromFloat(intersected_rect.x), @intFromFloat(intersected_rect.y), @intFromFloat(intersected_rect.width), @intFromFloat(intersected_rect.height) ); } fn endScissor(self: *UI) void { rl.endScissorMode(); _ = self.scissor_stack.pop(); if (self.scissor_stack.len > 0) { const top_scissor_rect = self.scissor_stack.buffer[self.scissor_stack.len - 1]; rl.endScissorMode(); rl.beginScissorMode( @intFromFloat(top_scissor_rect.x), @intFromFloat(top_scissor_rect.y), @intFromFloat(top_scissor_rect.width), @intFromFloat(top_scissor_rect.height) ); } } fn getKeySeed(self: *UI) u64 { var maybe_current = self.parentBox(); while (maybe_current) |current| { if (!current.key.isNil()) { return current.key.hash; } maybe_current = null; if (current.tree.parent_index) |parent_index| { maybe_current = &self.boxes.buffer[parent_index]; } } return 0; } pub fn keyFromString(self: *UI, text: []const u8) Key { return Key.initString(self.getKeySeed(), text); } pub fn signal(self: *UI, box: *Box) Signal { var result = Signal{}; if (box.key.isNil()) { log.warn("Called signal() with nil key", .{}); return result; } const rect = box.rect(); var clipped_rect = rect; { var parent_iter = box.iterParents(); while (parent_iter.next()) |parent| { if (parent.flags.contains(.clip_view)) { clipped_rect = rect_utils.intersect(clipped_rect, parent.rect()); } } } const key = box.key; const clickable = box.flags.contains(.clickable); const draggable = box.flags.contains(.draggable); const scrollable = box.flags.contains(.scrollable); const is_mouse_inside = rect_utils.isInsideVec2(clipped_rect, self.mouse); var event_index: usize = 0; while (event_index < self.events.len) { var taken = false; const event: Event = self.events.buffer[event_index]; const is_active = self.isKeyActive(key); if (event == .mouse_pressed and clickable and is_mouse_inside) { const mouse_button = event.mouse_pressed; result.insertMousePressed(mouse_button); self.active_box_keys.put(mouse_button, key); taken = true; } if (event == .mouse_released and clickable and is_active and is_mouse_inside) { const mouse_button = event.mouse_released; result.insertMouseReleased(mouse_button); result.insertMouseClicked(mouse_button); self.active_box_keys.remove(mouse_button); taken = true; } if (event == .mouse_released and clickable and is_active and !is_mouse_inside) { const mouse_button = event.mouse_released; result.insertMouseReleased(mouse_button); self.hot_box_key = null; self.active_box_keys.remove(mouse_button); taken = true; } if (event == .mouse_released and clickable and !is_mouse_inside) { result.clicked_outside = true; } if (event == .mouse_scroll and is_mouse_inside and scrollable) { result.scroll = event.mouse_scroll; result.flags.insert(.scrolled); taken = true; } if (taken) { _ = self.events.swapRemove(event_index); } else { event_index += 1; } } if (draggable and self.mouse_delta.equals(Vec2.zero()) == 0) { const mouse_buttons = [_]rl.MouseButton{ .mouse_button_left, .mouse_button_right, .mouse_button_middle }; inline for (mouse_buttons) |mouse_button| { const active_box = self.active_box_keys.get(mouse_button); if (active_box != null and active_box.?.eql(key)) { result.insertMouseDragged(mouse_button); result.drag = self.mouse_delta; } } } if (is_mouse_inside and clickable) { if (self.hot_box_key == null) { self.hot_box_key = key; } } result.hot = self.isKeyHot(box.key); result.active = self.isKeyActive(box.key); result.relative_mouse = self.mouse.subtract(rect_utils.position(rect)); result.mouse = self.mouse; result.is_mouse_inside = is_mouse_inside; result.shift_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); result.ctrl_modifier = rl.isKeyDown(rl.KeyboardKey.key_left_control) or rl.isKeyDown(rl.KeyboardKey.key_right_control); return result; } pub fn isKeyHot(self: *UI, key: Key) bool { if (self.hot_box_key) |hot_box_key| { return hot_box_key.eql(key); } else { return false; } } pub fn isKeyActive(self: *UI, key: Key) bool { inline for (std.meta.fields(rl.MouseButton)) |mouse_button_field| { const mouse_button: rl.MouseButton = @enumFromInt(mouse_button_field.value); if (self.active_box_keys.get(mouse_button)) |active_box| { if (active_box.eql(key)) { return true; } } } return false; } pub fn isKeyActiveAny(self: *UI) bool { return self.active_box_keys.count() != 0; } pub fn isKeyboardPressed(self: *UI, key: rl.KeyboardKey) bool { const key_u32: u32 = @intCast(@intFromEnum(key)); for (0.., self.events.slice()) |i, _event| { const event: Event = _event; if (event == .key_pressed and event.key_pressed == key_u32) { _ = self.events.swapRemove(i); return true; } } return false; } pub fn isKeyboardPressedOrHeld(self: *UI, key: rl.KeyboardKey) bool { return self.isKeyboardPressed(key) or rl.isKeyPressedRepeat(key); } pub fn hasKeyboardPressess(self: *UI) bool { for (self.events.slice()) |_event| { const event: Event = _event; if (event == .key_pressed) { return true; } } return false; } pub fn iterCharacterPressess(self: *UI) CharPressIterator { return CharPressIterator{ .events = &self.events, }; } pub fn isShiftDown(self: *UI) bool { _ = self; return rl.isKeyDown(rl.KeyboardKey.key_left_shift) or rl.isKeyDown(rl.KeyboardKey.key_right_shift); } pub fn isCtrlDown(self: *UI) bool { _ = self; return rl.isKeyDown(rl.KeyboardKey.key_left_control) or rl.isKeyDown(rl.KeyboardKey.key_right_control); } // --------------------------------- Widgets ----------------------------------------- // pub const TextInputStorage = struct { buffer: std.ArrayList(u8), modified: bool = false, editing: bool = false, last_pressed_at_ns: i128 = 0, cursor_start: usize = 0, cursor_stop: usize = 0, shown_slice_start: f32 = 0, shown_slice_end: f32 = 0, pub fn init(allocator: std.mem.Allocator) TextInputStorage { return TextInputStorage{ .buffer = std.ArrayList(u8).init(allocator) }; } pub fn deinit(self: TextInputStorage) void { self.buffer.deinit(); } pub fn setText(self: *TextInputStorage, text: []const u8) !void { self.modified = true; self.buffer.clearAndFree(); try self.buffer.appendSlice(text); } pub fn insertSingle(self: *TextInputStorage, index: usize, symbol: u8) !void { self.modified = true; try self.buffer.insert(index, symbol); if (self.cursor_start >= index) { self.cursor_start += 1; } if (self.cursor_stop >= index) { self.cursor_stop += 1; } } fn deleteSingle(self: *TextInputStorage, index: usize) void { self.deleteMany(index, index+1); } /// If `from` and `to` are the same number, nothing will be deleted fn deleteMany(self: *TextInputStorage, from: usize, to: usize) void { const from_clamped = std.math.clamp(from, 0, self.buffer.items.len); const to_clamped = std.math.clamp(to , 0, self.buffer.items.len); if (from_clamped == to_clamped) return; self.modified = true; const lower = @min(from_clamped, to_clamped); const upper = @max(from_clamped, to_clamped); assert(lower < upper); const deleted_count = (upper - lower); std.mem.copyForwards(u8, self.buffer.items[lower..], self.buffer.items[upper..]); self.buffer.items.len -= deleted_count; if (self.cursor_start > upper) { self.cursor_start -= deleted_count; } else if (self.cursor_start > lower) { self.cursor_start = lower; } if (self.cursor_stop > upper) { self.cursor_stop -= deleted_count; } else if (self.cursor_stop > lower) { self.cursor_stop = lower; } } fn getCharOffsetX(self: *const TextInputStorage, font: FontFace, index: usize) f32 { const text = self.buffer.items; return font.measureWidth(text[0..index]); } fn getCharIndex(self: *const TextInputStorage, font: FontFace, x: f32) usize { var measure_opts = FontFace.MeasureOptions{ .up_to_width = x + self.shown_slice_start }; const before_size = font.measureTextEx(self.buffer.items, &measure_opts).x; const index = measure_opts.last_codepoint_index; if (index+1 < self.buffer.items.len) { const after_size = self.getCharOffsetX(font, index + 1); if (@abs(before_size - x) > @abs(after_size - x)) { return index + 1; } } return index; } fn nextJumpPoint(self: *const TextInputStorage, index: usize, step: isize) usize { assert(step == 1 or step == -1); // Only tested for these cases var i = index; const text = self.buffer.items; if (step > 0) { var prev_whitespace = std.ascii.isWhitespace(text[i]); while (true) { const cur_whitespace = std.ascii.isWhitespace(text[i]); if (!prev_whitespace and cur_whitespace) { break; } prev_whitespace = cur_whitespace; const next_i = @as(isize, @intCast(i)) + step; if (next_i > text.len) { break; } i = @intCast(next_i); if (i == text.len) { break; } } } else { if (index == 0) return index; i = @intCast(@as(isize, @intCast(i)) + step); var cur_whitespace = std.ascii.isWhitespace(text[i]); while (true) { const next_i = @as(isize, @intCast(i)) + step; if (next_i < 0) { break; } const next_whitespace = std.ascii.isWhitespace(text[@intCast(next_i)]); if (!cur_whitespace and next_whitespace) { break; } cur_whitespace = next_whitespace; i = @intCast(next_i); } } return i; } pub fn textLength(self: *TextInputStorage) []const u8 { return self.buffer.items; } fn cursorSlice(self: *const TextInputStorage) []const u8 { if (self.cursor_start == self.cursor_stop) { return self.buffer.items[self.cursor_start..(self.cursor_start+1)]; } else { const lower = @min(self.cursor_start, self.cursor_stop); const upper = @max(self.cursor_start, self.cursor_stop); return self.buffer.items[lower..upper]; } } fn insertMany(self: *TextInputStorage, index: usize, text: []const u8) !void { if (index > self.buffer.items.len) return; self.modified = true; try self.buffer.insertSlice(index, text); if (self.cursor_start >= index) { self.cursor_start += text.len; } if (self.cursor_stop >= index) { self.cursor_stop += text.len; } } }; pub const TextInputOptions = struct { key: Key, storage: *TextInputStorage, editable: bool = true, initial: ?[]const u8 = null, placeholder: ?[]const u8 = null, text_color: rl.Color = srcery.black }; pub const NumberInputOptions = struct { key: Key, storage: *TextInputStorage, invalid: bool = false, editable: bool = true, initial: ?[]const u8 = null, placeholder: ?[]const u8 = null, text_color: rl.Color = srcery.black, invalid_color: rl.Color = srcery.red }; pub const CheckboxOptions = struct { value: *bool, label: ?[]const u8 = null }; pub const FileInputOptions = struct { key: Key, allocator: std.mem.Allocator, file_picker: *?Platform.FilePickerId, open_dialog: bool = true, path: ?[]const u8 = null }; pub fn mouseTooltip(self: *UI) *Box { const tooltip = self.getBoxByKey(mouse_tooltip_box_key).?; return tooltip; } pub fn button(self: *UI, key: UI.Key) *Box { return self.createBox(.{ .key = key, .size_x = Sizing.initFixed(.text), .size_y = Sizing.initFixed(.text), .flags = &.{ .draw_hot, .draw_active, .clickable }, .padding = Padding{ .bottom = self.rem(0.5), .top = self.rem(0.5), .left = self.rem(1), .right = self.rem(1) }, .hot_cursor = .mouse_cursor_pointing_hand, }); } pub fn textButton(self: *UI, text: []const u8) *Box { var box = self.button(self.keyFromString(text)); box.setText(text); box.alignment.x = .center; box.alignment.y = .center; return box; } pub fn label(self: *UI, comptime fmt: []const u8, args: anytype) *Box { const box = self.createBox(.{ .size_x = Sizing.initFixed(.text), .size_y = Sizing.initFixed(.text), .flags = &.{ .wrap_text } }); box.setFmtText(fmt, args); return box; } pub fn beginScrollbar(self: *UI, key: Key) *Box { const wrapper = self.createBox(.{ .key = key, .layout_direction = .left_to_right, .flags = &.{ .clip_view }, .size_x = Sizing.initGrowFull(), .size_y = Sizing.initGrowFull() }); wrapper.beginChildren(); const content_area = self.createBox(.{ .key = self.keyFromString("Scrollable content area"), .flags = &.{ .scrollable, .clip_view }, .size_x = Sizing.initGrowFull(), .size_y = Sizing.initFitChildren(), }); content_area.beginChildren(); const content_size = content_area.persistent.size.y; const visible_percent = clamp(wrapper.persistent.size.y / content_size, 0, 1); const sroll_offset = content_area.persistent.sroll_offset; content_area.view_offset.y = sroll_offset * (1 - visible_percent) * content_size; return content_area; } pub fn endScrollbar(self: *UI) void { const content_area = self.parentBox().?; content_area.endChildren(); const wrapper = self.parentBox().?; const visible_percent = clamp(wrapper.persistent.size.y / content_area.persistent.size.y, 0, 1); { const scrollbar_area = self.createBox(.{ .key = self.keyFromString("Scrollbar area"), .background = srcery.hard_black, .flags = &.{ .scrollable }, .size_x = .{ .fixed = .{ .pixels = 24 } }, .size_y = Sizing.initGrowFull() }); scrollbar_area.beginChildren(); defer scrollbar_area.endChildren(); const draggable = self.createBox(.{ .key = self.keyFromString("Scrollbar button"), .background = srcery.black, .flags = &.{ .draw_hot, .draw_active, .clickable, .draggable }, .borders = Borders.all(.{ .size = 4, .color = srcery.xgray3 }), .size_x = Sizing.initFixed(.{ .parent_percent = 1 }), .size_y = Sizing.initFixed(.{ .parent_percent = visible_percent }), .hot_cursor = .mouse_cursor_pointing_hand }); const sroll_offset = &content_area.persistent.sroll_offset; const scrollbar_height = scrollbar_area.persistent.size.y; const max_offset = scrollbar_height * (1 - visible_percent); draggable.setFloatY(content_area.persistent.position.y + sroll_offset.* * max_offset); const draggable_signal = self.signal(draggable); if (draggable_signal.dragged()) { sroll_offset.* += draggable_signal.drag.y / max_offset; } const scroll_speed = 16; const scrollbar_signal = self.signal(scrollbar_area); if (scrollbar_signal.scrolled()) { sroll_offset.* -= scrollbar_signal.scroll.y / max_offset * scroll_speed; } const content_area_signal = self.signal(content_area); if (content_area_signal.scrolled()) { sroll_offset.* -= content_area_signal.scroll.y / max_offset * scroll_speed; } sroll_offset.* = std.math.clamp(sroll_offset.*, 0, 1); } wrapper.endChildren(); } pub fn textInput(self: *UI, opts: TextInputOptions) !void { const now = std.time.nanoTimestamp(); const container = self.createBox(.{ .key = opts.key, .size_x = Sizing.initGrowUpTo(.{ .pixels = 200 }), .size_y = Sizing.initFixed(Unit.initPixels(self.rem(1))), .flags = &.{ .clickable, .clip_view, .draggable }, .background = srcery.bright_white, .align_y = .center, .padding = UI.Padding.all(self.rem(0.25)), }); container.beginChildren(); defer container.endChildren(); const font = Assets.font(container.font); const storage = opts.storage; const storage_text = &storage.buffer; if (opts.initial != null and container.created) { storage_text.clearAndFree(); try storage_text.appendSlice(opts.initial.?); } const cursor_start_x = storage.getCharOffsetX(font, storage.cursor_start); const cursor_stop_x = storage.getCharOffsetX(font, storage.cursor_stop); { // Text visuals var text_color = opts.text_color; var text: []const u8 = storage_text.items; if (opts.placeholder != null and text.len == 0) { text = opts.placeholder.?; text_color = text_color.alpha(0.6); } const text_size = font.measureText(text); const visible_text_width = container.persistent.size.x - container.padding.byAxis(.X); const shown_window_size = @min(visible_text_width, text_size.x); if (storage.shown_slice_end - storage.shown_slice_start != shown_window_size) { const shown_slice_middle = (storage.shown_slice_end + storage.shown_slice_start) / 2; storage.shown_slice_start = shown_slice_middle - shown_window_size/2; storage.shown_slice_end = shown_slice_middle + shown_window_size/2; } if (storage.shown_slice_end > text_size.x) { storage.shown_slice_end = shown_window_size; storage.shown_slice_start = storage.shown_slice_end - shown_window_size; } else if (storage.shown_slice_start < 0) { storage.shown_slice_start = 0; storage.shown_slice_end = shown_window_size; } if (cursor_stop_x > storage.shown_slice_end) { storage.shown_slice_start = cursor_stop_x - shown_window_size; storage.shown_slice_end = cursor_stop_x; } if (cursor_stop_x < storage.shown_slice_start) { storage.shown_slice_start = cursor_stop_x; storage.shown_slice_end = cursor_stop_x + shown_window_size; } _ = self.createBox(.{ .text_color = text_color, .text = text, .float_relative_to = container, .float_rect = Rect{ .x = container.padding.left - storage.shown_slice_start, .y = 0, .width = visible_text_width, .height = container.persistent.size.y }, .align_y = .center, .align_x = .start }); } const container_signal = self.signal(container); if (opts.editable and container_signal.hot) { container.borders = UI.Borders.all(.{ .color = srcery.red, .size = 2 }); } // Text editing visuals if (storage.editing) { const blink_period = std.time.ns_per_s; const blink = @mod(now - storage.last_pressed_at_ns, blink_period) < 0.5 * blink_period; const cursor_color = srcery.hard_black; if (storage.cursor_start == storage.cursor_stop and blink) { _ = self.createBox(.{ .background = cursor_color, .float_relative_to = container, .float_rect = Rect{ .x = container.padding.left + cursor_start_x - storage.shown_slice_start, .y = container.padding.top, .width = 2, .height = container.persistent.size.y - container.padding.byAxis(.Y) }, }); } if (storage.cursor_start != storage.cursor_stop) { const lower_cursor_x = @min(cursor_start_x, cursor_stop_x); const upper_cursor_x = @max(cursor_start_x, cursor_stop_x); _ = self.createBox(.{ .background = cursor_color.alpha(0.25), .float_relative_to = container, .float_rect = Rect{ .x = container.padding.left + lower_cursor_x - storage.shown_slice_start, .y = container.padding.top, .width = upper_cursor_x - lower_cursor_x, .height = container.persistent.size.y - container.padding.byAxis(.Y) }, }); } } if (opts.editable and container_signal.active) { storage.editing = true; } if (self.isKeyActiveAny() and !container_signal.active) { storage.editing = false; } // Text input controls if (storage.editing) { const shift = container_signal.shift_modifier; const ctrl = container_signal.ctrl_modifier; var no_blinking = false; { // Cursor movement controls var move_cursor_dir: i32 = 0; if (self.isKeyboardPressedOrHeld(.key_left)) { move_cursor_dir -= 1; } if (self.isKeyboardPressedOrHeld(.key_right)) { move_cursor_dir += 1; } if (move_cursor_dir != 0) { if (shift) { if (ctrl) { storage.cursor_stop = storage.nextJumpPoint(storage.cursor_stop, move_cursor_dir); } else { const cursor_stop: isize = @intCast(storage.cursor_stop); storage.cursor_stop = @intCast(std.math.clamp( cursor_stop + move_cursor_dir, 0, @as(isize, @intCast(storage_text.items.len)) )); } } else { if (storage.cursor_start != storage.cursor_stop) { const lower = @min(storage.cursor_start, storage.cursor_stop); const upper = @max(storage.cursor_start, storage.cursor_stop); if (move_cursor_dir < 0) { if (ctrl) { storage.cursor_start = storage.nextJumpPoint(lower, -1); } else { storage.cursor_start = lower; } } else { if (ctrl) { storage.cursor_start = storage.nextJumpPoint(upper, 1); } else { storage.cursor_start = upper; } } storage.cursor_stop = storage.cursor_start; } else { var cursor = storage.cursor_start; if (ctrl) { cursor = storage.nextJumpPoint(cursor, move_cursor_dir); } else { cursor = @intCast(std.math.clamp( @as(isize, @intCast(cursor)) + move_cursor_dir, 0, @as(isize, @intCast(storage_text.items.len)) )); } storage.cursor_start = cursor; storage.cursor_stop = cursor; } } no_blinking = true; } } { // Cursor movement with mouse var mouse = container_signal.relative_mouse; mouse.x -= container.padding.left; mouse.y -= container.padding.top; const mouse_index = storage.getCharIndex(font, mouse.x); if (container_signal.flags.contains(.left_pressed)) { storage.cursor_start = mouse_index; storage.cursor_stop = mouse_index; no_blinking = true; } if (container_signal.flags.contains(.left_dragging)) { storage.cursor_stop = mouse_index; no_blinking = true; } } // Deletion if (self.isKeyboardPressedOrHeld(.key_backspace) and storage_text.items.len > 0) { if (storage.cursor_start == storage.cursor_stop) { if (storage.cursor_start > 0) { storage.deleteMany(storage.cursor_start-1, storage.cursor_start); } } else { storage.deleteMany(storage.cursor_start, storage.cursor_stop); } no_blinking = true; } // Common commands (E.g. Ctrl+A, Ctrl+C, etc.) if (ctrl) { if (self.isKeyboardPressedOrHeld(.key_x)) { if (storage.cursor_start != storage.cursor_stop) { const allocator = storage.buffer.allocator; const text_z = try allocator.dupeZ(u8, storage.cursorSlice()); defer allocator.free(text_z); rl.setClipboardText(text_z); storage.deleteMany(storage.cursor_start, storage.cursor_stop); } } else if (self.isKeyboardPressedOrHeld(.key_a)) { storage.cursor_start = 0; storage.cursor_stop = storage_text.items.len; } else if (self.isKeyboardPressedOrHeld(.key_c)) { if (storage.cursor_start != storage.cursor_stop) { const allocator = storage.buffer.allocator; const text_z = try allocator.dupeZ(u8, storage.cursorSlice()); defer allocator.free(text_z); rl.setClipboardText(text_z); } } else if (self.isKeyboardPressedOrHeld(.key_v)) { if (storage.cursor_start != storage.cursor_stop) { storage.deleteMany(storage.cursor_start, storage.cursor_stop); } const clipboard = rl.getClipboardText(); try storage.insertMany(storage.cursor_start, clipboard); } } { // Insertion // TODO: Handle UTF8 characters var char_iter = self.iterCharacterPressess(); while (char_iter.next()) |char| { if (char <= 255 and std.ascii.isPrint(@intCast(char))) { char_iter.consume(); if (storage.cursor_start != storage.cursor_stop) { storage.deleteMany(storage.cursor_start, storage.cursor_stop); } try storage.insertSingle(storage.cursor_start, @intCast(char)); no_blinking = true; } } } if (no_blinking) { storage.last_pressed_at_ns = now; } if (self.isKeyboardPressed(.key_escape) or self.isKeyboardPressed(.key_enter)) { storage.editing = false; } if (container_signal.clicked_outside and !container_signal.is_mouse_inside) { storage.editing = false; } if (!opts.editable) { storage.editing = false; } } } pub fn numberInput(self: *UI, T: type, opts: NumberInputOptions) !?T { const storage = opts.storage; var text_opts = TextInputOptions{ .key = opts.key, .storage = opts.storage, .initial = opts.initial, .text_color = opts.text_color, .placeholder = opts.placeholder, .editable = opts.editable }; var is_invalid = opts.invalid; if (storage.buffer.items.len > 0 and std.meta.isError(std.fmt.parseFloat(T, storage.buffer.items))) { is_invalid = true; } if (is_invalid) { text_opts.text_color = opts.invalid_color; } try self.textInput(text_opts); if (std.fmt.parseFloat(T, storage.buffer.items)) |new_value| { return new_value; } else |_| { return null; } } pub fn checkbox(self: *UI, opts: CheckboxOptions) void { const container = self.createBox(.{ .key = UI.Key.initPtr(opts.value), .size_x = UI.Sizing.initFitChildren(), .size_y = UI.Sizing.initFitChildren(), .flags = &.{ .draw_hot, .draw_active, .clickable }, .hot_cursor = .mouse_cursor_pointing_hand, .layout_direction = .left_to_right, .layout_gap = self.rem(0.25) }); container.beginChildren(); defer container.endChildren(); const container_signal = self.signal(container); const marker = self.createBox(.{ .key = self.keyFromString("checkbox marker"), .size_x = UI.Sizing.initFixedPixels(self.rem(1)), .size_y = UI.Sizing.initFixedPixels(self.rem(1)), .background = srcery.bright_white, .visual_hot = container_signal.hot, .visual_active = container_signal.active, .flags = &.{ .draw_hot, .draw_active }, }); if (opts.label) |text| { _ = self.createBox(.{ .size_x = Sizing.initFixed(.text), .size_y = Sizing.initFixed(.text), .text = text }); } if (opts.value.*) { marker.texture = Assets.checkbox_mark; } if (container_signal.clicked()) { opts.value.* = !opts.value.*; } } pub fn fileInput(self: *UI, opts: FileInputOptions) ?[]u8 { var result: ?[]u8 = null; const container = self.createBox(.{ .key = opts.key, .size_x = Sizing.initGrowUpTo(.{ .pixels = 200 }), .size_y = Sizing.initFixed(Unit.initPixels(self.rem(1))), .flags = &.{ .clickable, .clip_view, .draw_hot, .draw_active }, .background = srcery.bright_white, .align_y = .center, .padding = UI.Padding.all(self.rem(0.25)), .hot_cursor = .mouse_cursor_pointing_hand, .layout_gap = self.rem(0.5) }); container.beginChildren(); defer container.endChildren(); _ = self.createBox(.{ .texture = Assets.file, .size_x = UI.Sizing.initFixed(.{ .pixels = 16 }), .size_y = UI.Sizing.initFixed(.{ .pixels = 16 }) }); const path_box = self.createBox(.{ .size_x = UI.Sizing.initGrowFull(), .size_y = UI.Sizing.initGrowFull(), .text_color = srcery.black }); if (opts.path) |path| { path_box.setText(std.fs.path.basename(path)); container.tooltip = path; } else { path_box.setText(""); } if (opts.file_picker.* != null) { if (Platform.waitUntilFilePickerDone(opts.allocator, opts.file_picker)) |path| { result = path; } } else { const container_signal = self.signal(container); if (container_signal.clicked()) { var file_open_options: Platform.OpenFileOptions = .{}; if (opts.open_dialog) { file_open_options.style = .open; file_open_options.file_must_exist = true; } else { file_open_options.style = .save; file_open_options.prompt_overwrite = true; } file_open_options.appendFilter("All", "*") catch unreachable; file_open_options.appendFilter("Binary", "*.bin") catch unreachable; if (Platform.spawnFilePicker(&file_open_options)) |file_picker_id| { opts.file_picker.* = file_picker_id; } else |e| { log.err("Failed to open file picker: {}", .{e}); } } } return result; }