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 }