lib/gui/src/widget.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const model = @import("model.zig");
3
4 const RootFrame = model.RootFrame;
5 const UiNode = model.UiNode;
6 const UiPoint = model.UiPoint;
7 const WidgetFrame = model.WidgetFrame;
8 const ui_state_disabled = model.ui_state_disabled;
9
10 pub const ActionInteraction = struct {
11 hovered_action: ?[]const u8 = null,
12 captured_action: ?[]const u8 = null,
13 focused_action: ?[]const u8 = null,
14 };
15
16 pub const InteractionWidgets = struct {
17 hovered_widget: ?u64 = null,
18 captured_widget: ?u64 = null,
19 focused_widget: ?u64 = null,
20 };
21
22 pub fn hitTest(widgets: []const WidgetFrame, root_id: u64, x: f32, y: f32) ?*const WidgetFrame {
23 const index = hitTestIndex(widgets, root_id, x, y) orelse return null;
24 return &widgets[index];
25 }
26
27 pub fn hitTestIndex(widgets: []const WidgetFrame, root_id: u64, x: f32, y: f32) ?usize {
28 var index: usize = widgets.len;
29 while (index > 0) {
30 index -= 1;
31 const widget = &widgets[index];
32 if (widget.root_id != root_id) continue;
33 if (isDisabledWidget(widget)) continue;
34 if (widget.visible_rect.width <= 0 or widget.visible_rect.height <= 0) continue;
35 if (model.pointInRect(widget.rect, x, y) and model.pointInRect(widget.visible_rect, x, y)) return index;
36 }
37 return null;
38 }
39
40 pub fn hitTestActivatable(widgets: []const WidgetFrame, root_id: u64, x: f32, y: f32) ?*const WidgetFrame {
41 const hit = hitTest(widgets, root_id, x, y) orelse return null;
42 if (isActivatable(hit)) return hit;
43 const hit_index = findIndexById(widgets, root_id, hit.widget_id) orelse return null;
44 var index = hit_index;
45 while (index > 0) {
46 index -= 1;
47 const candidate = &widgets[index];
48 if (candidate.root_id != root_id) continue;
49 if (index + candidate.subtree_size <= hit_index) continue;
50 if (!isActivatable(candidate)) continue;
51 if (model.pointInRect(candidate.rect, x, y) and model.pointInRect(candidate.visible_rect, x, y)) return candidate;
52 }
53 return null;
54 }
55
56 pub fn findById(widgets: []const WidgetFrame, root_id: u64, widget_id: u64) ?*const WidgetFrame {
57 for (widgets) |*widget| {
58 if (widget.root_id == root_id and widget.widget_id == widget_id) return widget;
59 }
60 return null;
61 }
62
63 pub fn findIndexById(widgets: []const WidgetFrame, root_id: u64, widget_id: u64) ?usize {
64 for (widgets, 0..) |*widget, index| {
65 if (widget.root_id == root_id and widget.widget_id == widget_id) return index;
66 }
67 return null;
68 }
69
70 pub fn rootWidgetIndex(roots: []const RootFrame, root_id: u64) ?usize {
71 for (roots) |root| {
72 if (root.root_id == root_id and root.widget_count > 0) return root.widget_start;
73 }
74 return null;
75 }
76
77 pub fn focused(widgets: []const WidgetFrame, root_id: u64, widget_id: ?u64) ?*const WidgetFrame {
78 const current = widget_id orelse return null;
79 const widget = findById(widgets, root_id, current) orelse return null;
80 return if (isFocusableForTraversal(root_id, widget)) widget else null;
81 }
82
83 pub fn findNodeByAction(root: *const UiNode, action: []const u8) ?*const UiNode {
84 if (action.len == 0) return null;
85 if (std.mem.eql(u8, root.action, action)) return root;
86 for (root.children) |*child| {
87 if (findNodeByAction(child, action)) |found| return found;
88 }
89 return null;
90 }
91
92 pub fn findNodeByRole(root: *const UiNode, role: []const u8) ?*const UiNode {
93 if (role.len == 0) return null;
94 if (std.mem.eql(u8, root.role, role)) return root;
95 for (root.children) |*child| {
96 if (findNodeByRole(child, role)) |found| return found;
97 }
98 return null;
99 }
100
101 pub fn interactionWidgetsForActions(root: *const UiNode, interaction: ActionInteraction) InteractionWidgets {
102 return .{
103 .hovered_widget = nodeIdForAction(root, interaction.hovered_action),
104 .captured_widget = nodeIdForAction(root, interaction.captured_action),
105 .focused_widget = nodeIdForAction(root, interaction.focused_action),
106 };
107 }
108
109 pub fn findByAction(widgets: []const WidgetFrame, root_id: u64, action: []const u8) ?*const WidgetFrame {
110 if (action.len == 0) return null;
111 for (widgets) |*widget| {
112 if (widget.root_id == root_id and std.mem.eql(u8, widget.action, action)) return widget;
113 }
114 return null;
115 }
116
117 pub fn findByRole(widgets: []const WidgetFrame, root_id: u64, role: []const u8) ?*const WidgetFrame {
118 if (role.len == 0) return null;
119 for (widgets) |*widget| {
120 if (widget.root_id == root_id and std.mem.eql(u8, widget.role, role)) return widget;
121 }
122 return null;
123 }
124
125 pub fn findByTextContaining(widgets: []const WidgetFrame, root_id: u64, needle: []const u8) ?*const WidgetFrame {
126 if (needle.len == 0) return null;
127 for (widgets) |*widget| {
128 if (widget.root_id != root_id) continue;
129 const text = widget.text orelse continue;
130 if (std.mem.indexOf(u8, text.content, needle) != null) return widget;
131 }
132 return null;
133 }
134
135 pub fn pointForAction(widgets: []const WidgetFrame, root_id: u64, action: []const u8) ?UiPoint {
136 const widget = findByAction(widgets, root_id, action) orelse return null;
137 if (!isActivatable(widget)) return null;
138 return pointForWidget(widget);
139 }
140
141 pub fn pointForTextContaining(widgets: []const WidgetFrame, root_id: u64, needle: []const u8) ?UiPoint {
142 const widget = findByTextContaining(widgets, root_id, needle) orelse return null;
143 return pointForWidget(widget);
144 }
145
146 pub fn pointForWidget(widget: *const WidgetFrame) ?UiPoint {
147 if (widget.visible_rect.width <= 0 or widget.visible_rect.height <= 0) return null;
148 return .{
149 .x = widget.visible_rect.x + widget.visible_rect.width * 0.5,
150 .y = widget.visible_rect.y + widget.visible_rect.height * 0.5,
151 };
152 }
153
154 pub fn isActivatable(widget: *const WidgetFrame) bool {
155 return !isDisabledWidget(widget) and widget.kind != .text_input and (widget.kind == .button or widget.action.len != 0);
156 }
157
158 pub fn isFocusableForTraversal(root_id: u64, widget: *const WidgetFrame) bool {
159 return widget.root_id == root_id and
160 widget.focusable and
161 !isDisabledWidget(widget) and
162 widget.rect.width > 0 and
163 widget.rect.height > 0;
164 }
165
166 pub fn isDisabledNode(node: *const UiNode) bool {
167 return (node.state_flags & ui_state_disabled) != 0;
168 }
169
170 pub fn isDisabledWidget(widget: *const WidgetFrame) bool {
171 return (widget.state_flags & ui_state_disabled) != 0;
172 }
173
174 pub fn isTextEditable(widget: *const WidgetFrame) bool {
175 return widget.kind == .text_input and !isDisabledWidget(widget);
176 }
177
178 fn nodeIdForAction(root: *const UiNode, action: ?[]const u8) ?u64 {
179 const action_text = action orelse return null;
180 const node = findNodeByAction(root, action_text) orelse return null;
181 return node.widget_id;
182 }
183
184 test "activatable hit testing promotes child text hits to the owning widget" {
185 const widgets = [_]WidgetFrame{
186 .{
187 .root_id = 7,
188 .widget_id = 10,
189 .kind = .button,
190 .rect = .{ .x = 0, .y = 0, .width = 120, .height = 32 },
191 .visible_rect = .{ .x = 0, .y = 0, .width = 120, .height = 32 },
192 .paint = .{},
193 .scroll = .{},
194 .constraints = .{},
195 .content_size = .{ .width = 120, .height = 32 },
196 .focusable = true,
197 .action = "demo.activate",
198 .subtree_size = 2,
199 },
200 .{
201 .root_id = 7,
202 .widget_id = 11,
203 .kind = .label,
204 .rect = .{ .x = 8, .y = 6, .width = 72, .height = 18 },
205 .visible_rect = .{ .x = 8, .y = 6, .width = 72, .height = 18 },
206 .paint = .{},
207 .scroll = .{},
208 .constraints = .{},
209 .content_size = .{ .width = 72, .height = 18 },
210 .focusable = false,
211 },
212 };
213
214 const raw = hitTest(widgets[0..], 7, 12, 12) orelse return error.MissingRawHit;
215 try std.testing.expectEqual(@as(u64, 11), raw.widget_id);
216 const promoted = hitTestActivatable(widgets[0..], 7, 12, 12) orelse return error.MissingPromotedHit;
217 try std.testing.expectEqual(@as(u64, 10), promoted.widget_id);
218 try std.testing.expectEqualStrings("demo.activate", promoted.action);
219 }
220
221 test "action and role lookup traverse surface trees and frame facts" {
222 const text = model.UiText{ .content = "Alpha row" };
223 const nodes = [_]UiNode{
224 .{
225 .widget_id = 11,
226 .kind = .button,
227 .action = "demo.open",
228 .role = "scene.graph-row",
229 },
230 .{
231 .widget_id = 12,
232 .kind = .label,
233 .role = "scene.preview",
234 .text = text,
235 },
236 };
237 const root = UiNode{
238 .widget_id = 10,
239 .role = "scene",
240 .children = nodes[0..],
241 };
242
243 try std.testing.expectEqual(@as(u64, 11), (findNodeByAction(&root, "demo.open") orelse return error.MissingActionNode).widget_id);
244 try std.testing.expectEqual(@as(u64, 12), (findNodeByRole(&root, "scene.preview") orelse return error.MissingRoleNode).widget_id);
245 try std.testing.expect(findNodeByAction(&root, "") == null);
246
247 const widgets = [_]WidgetFrame{
248 .{
249 .root_id = 7,
250 .widget_id = 11,
251 .kind = .button,
252 .rect = .{ .x = 0, .y = 0, .width = 80, .height = 24 },
253 .visible_rect = .{ .x = 0, .y = 0, .width = 80, .height = 24 },
254 .paint = .{},
255 .scroll = .{},
256 .constraints = .{},
257 .content_size = .{ .width = 80, .height = 24 },
258 .focusable = true,
259 .action = "demo.open",
260 .role = "scene.graph-row",
261 },
262 .{
263 .root_id = 7,
264 .widget_id = 12,
265 .kind = .label,
266 .rect = .{ .x = 90, .y = 0, .width = 100, .height = 24 },
267 .visible_rect = .{ .x = 90, .y = 0, .width = 100, .height = 24 },
268 .paint = .{},
269 .scroll = .{},
270 .constraints = .{},
271 .content_size = .{ .width = 100, .height = 24 },
272 .focusable = false,
273 .role = "scene.preview",
274 .has_text = true,
275 .text = text,
276 },
277 .{
278 .root_id = 8,
279 .widget_id = 13,
280 .kind = .button,
281 .rect = .{ .x = 0, .y = 0, .width = 80, .height = 24 },
282 .visible_rect = .{ .x = 0, .y = 0, .width = 80, .height = 24 },
283 .paint = .{},
284 .scroll = .{},
285 .constraints = .{},
286 .content_size = .{ .width = 80, .height = 24 },
287 .focusable = true,
288 .action = "demo.open",
289 },
290 .{
291 .root_id = 7,
292 .widget_id = 14,
293 .kind = .button,
294 .rect = .{ .x = 0, .y = 30, .width = 80, .height = 24 },
295 .visible_rect = .{ .x = 0, .y = 30, .width = 80, .height = 24 },
296 .paint = .{},
297 .scroll = .{},
298 .constraints = .{},
299 .content_size = .{ .width = 80, .height = 24 },
300 .focusable = true,
301 .action = "demo.disabled",
302 .state_flags = ui_state_disabled,
303 },
304 };
305
306 try std.testing.expectEqual(@as(u64, 11), (findByAction(widgets[0..], 7, "demo.open") orelse return error.MissingActionWidget).widget_id);
307 try std.testing.expectEqual(@as(u64, 12), (findByRole(widgets[0..], 7, "scene.preview") orelse return error.MissingRoleWidget).widget_id);
308 try std.testing.expectEqual(@as(u64, 12), (findByTextContaining(widgets[0..], 7, "row") orelse return error.MissingTextWidget).widget_id);
309 try std.testing.expectEqual(@as(u64, 13), (findByAction(widgets[0..], 8, "demo.open") orelse return error.MissingSecondRootActionWidget).widget_id);
310
311 const point = pointForAction(widgets[0..], 7, "demo.open") orelse return error.MissingActionPoint;
312 try std.testing.expectEqual(@as(f32, 40), point.x);
313 try std.testing.expectEqual(@as(f32, 12), point.y);
314 try std.testing.expect(pointForAction(widgets[0..], 7, "demo.disabled") == null);
315 const text_point = pointForTextContaining(widgets[0..], 7, "Alpha") orelse return error.MissingTextPoint;
316 try std.testing.expectEqual(@as(f32, 140), text_point.x);
317 try std.testing.expectEqual(@as(f32, 12), text_point.y);
318 }
319
320 test "action interaction maps action descriptors to widget ids" {
321 const nodes = [_]UiNode{
322 .{
323 .widget_id = 21,
324 .kind = .button,
325 .action = "inspector.tabs.items",
326 },
327 .{
328 .widget_id = 22,
329 .kind = .button,
330 .action = "inspector.action.refresh",
331 },
332 };
333 const root = UiNode{
334 .widget_id = 20,
335 .children = nodes[0..],
336 };
337
338 const mapped = interactionWidgetsForActions(&root, .{
339 .hovered_action = "inspector.tabs.items",
340 .captured_action = "inspector.action.refresh",
341 .focused_action = "inspector.tabs.items",
342 });
343
344 try std.testing.expectEqual(@as(?u64, 21), mapped.hovered_widget);
345 try std.testing.expectEqual(@as(?u64, 22), mapped.captured_widget);
346 try std.testing.expectEqual(@as(?u64, 21), mapped.focused_widget);
347 try std.testing.expectEqual(@as(?u64, null), interactionWidgetsForActions(&root, .{ .hovered_action = "" }).hovered_widget);
348 }