From 064e7b404b4abcd8b3b4ad8be3a54abf3e0361a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 10 Mar 2026 19:02:55 +0900 Subject: [PATCH] SemanticTree: unify interactivity detection logic --- src/SemanticTree.zig | 47 +++++++++++-------------------------- src/browser/interactive.zig | 6 ++--- src/cdp/AXNode.zig | 22 ----------------- 3 files changed, 17 insertions(+), 58 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index c713e5bb..7bea8866 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const log = @import("log.zig"); const isAllWhitespace = @import("string.zig").isAllWhitespace; const Page = lp.Page; +const interactive = @import("browser/interactive.zig"); const CData = @import("browser/webapi/CData.zig"); const Element = @import("browser/webapi/Element.zig"); @@ -40,7 +41,11 @@ prune: bool = false, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; var xpath_buffer: std.ArrayList(u8) = .{}; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { + const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { + log.err(.app, "listener map failed", .{ .err = err }); + return error.WriteFailed; + }; + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -49,7 +54,11 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; var xpath_buffer: std.ArrayList(u8) = .empty; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { + const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { + log.err(.app, "listener map failed", .{ .err = err }); + return error.WriteFailed; + }; + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -73,7 +82,7 @@ const NodeData = struct { node_name: []const u8, }; -fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize) !void { +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -112,48 +121,20 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (node.is(Element)) |el| { node_name = el.getTagNameLower(); - const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; - is_interactive = ax_role.isInteractive(); - if (el.is(Element.Html.Input)) |input| { - // Force all non-hidden inputs to be interactive - if (input._input_type != .hidden) { - is_interactive = true; - } value = input.getValue(); if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { options = try extractDataListOptions(list_id, self.page, self.arena); } } else if (el.is(Element.Html.TextArea)) |textarea| { - is_interactive = true; value = textarea.getValue(); } else if (el.is(Element.Html.Select)) |select| { - is_interactive = true; value = select.getValue(self.page); options = try extractSelectOptions(el.asNode(), self.page, self.arena); - } else if (el.getTag() == .button) { - is_interactive = true; - } - - const event_target = node.asEventTarget(); - if (self.page._event_manager.hasListener(event_target, "click") or - self.page._event_manager.hasListener(event_target, "mousedown") or - self.page._event_manager.hasListener(event_target, "mouseup") or - self.page._event_manager.hasListener(event_target, "keydown") or - self.page._event_manager.hasListener(event_target, "change") or - self.page._event_manager.hasListener(event_target, "input")) - { - is_interactive = true; } if (el.is(Element.Html)) |html_el| { - if (html_el.hasAttributeFunction(.onclick, self.page) or - html_el.hasAttributeFunction(.onmousedown, self.page) or - html_el.hasAttributeFunction(.onmouseup, self.page) or - html_el.hasAttributeFunction(.onkeydown, self.page) or - html_el.hasAttributeFunction(.onchange, self.page) or - html_el.hasAttributeFunction(.oninput, self.page)) - { + if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) { is_interactive = true; } } @@ -225,7 +206,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } gop.value_ptr.* += 1; - try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*); + try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets); } } diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index b0428a6c..5e85382f 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -178,12 +178,12 @@ pub fn collectInteractiveElements( return results.items; } -const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8)); +pub const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8)); /// Pre-build a map from event_target pointer → list of event type names. /// This lets both classifyInteractivity (O(1) "has any?") and /// getListenerTypes (O(1) "which ones?") avoid re-iterating per element. -fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { +pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { var map = ListenerTargetMap{}; // addEventListener registrations @@ -209,7 +209,7 @@ fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { return map; } -fn classifyInteractivity( +pub fn classifyInteractivity( el: *Element, html_el: *Element.Html, listener_targets: ListenerTargetMap, diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 73571e7d..b2cc236e 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -567,28 +567,6 @@ pub const AXRole = enum(u8) { StaticText, // zig fmt: on - pub fn isInteractive(self: AXRole) bool { - return switch (self) { - .button, - .link, - .checkbox, - .radio, - .textbox, - .combobox, - .searchbox, - .slider, - .spinbutton, - .@"switch", - .menuitem, - .color, - .date, - .file, - .month, - => true, - else => false, - }; - } - fn fromNode(node: *DOMNode) !AXRole { return switch (node._type) { .document => return .RootWebArea, // Chrome specific.