Skip to content

Commit 0253de8

Browse files
committed
Add a dumb renderer to get coordinates
FlatRenderer positions items on a single row, giving each a height and width of 1. Added getBoundingClientRect to the DOMelement which, when requested for the first time, will place the item in with the renderer. The goal here is to give elements a fixed position and to make it easy to map x,y coordinates onto an element. This should work, at least with puppeteer, since it first requests the boundingClientRect before issuing a click.
1 parent 6475752 commit 0253de8

File tree

10 files changed

+285
-23
lines changed

10 files changed

+285
-23
lines changed

src/browser/browser.zig

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,14 @@ pub const Page = struct {
298298
// current_script could by fetch module to resolve module's url to fetch.
299299
current_script: ?*const Script = null,
300300

301+
renderer: FlatRenderer,
302+
301303
fn init(session: *Session) Page {
304+
const arena = session.browser.page_arena.allocator();
302305
return .{
306+
.arena = arena,
303307
.session = session,
304-
.arena = session.browser.page_arena.allocator(),
308+
.renderer = FlatRenderer.init(arena),
305309
};
306310
}
307311

@@ -423,6 +427,32 @@ pub const Page = struct {
423427
}
424428
}
425429

430+
pub const ClickResult = union(enum) {
431+
navigate: std.Uri,
432+
};
433+
434+
pub fn click(self: *Page, allocator: Allocator, x: u32, y: u32) !?ClickResult {
435+
const element = self.renderer.getElementAtPosition(x, y) orelse return null;
436+
437+
const event = try parser.eventCreate();
438+
defer parser.eventDestroy(event);
439+
440+
try parser.eventInit(event, "click", .{ .bubbles = true, .cancelable = true });
441+
if ((try parser.eventDefaultPrevented(event)) == true) {
442+
return null;
443+
}
444+
445+
const node = parser.elementToNode(element);
446+
const tag = try parser.nodeName(node);
447+
if (std.ascii.eqlIgnoreCase(tag, "a")) {
448+
const href = (try parser.elementGetAttribute(element, "href")) orelse return null;
449+
var buf = try allocator.alloc(u8, 1024);
450+
return .{ .navigate = try std.Uri.resolve_inplace(self.uri, href, &buf) };
451+
}
452+
453+
return null;
454+
}
455+
426456
// https://html.spec.whatwg.org/#read-html
427457
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8, aux_data: ?[]const u8) !void {
428458
const arena = self.arena;
@@ -462,6 +492,7 @@ pub const Page = struct {
462492
try session.env.setUserContext(.{
463493
.uri = self.uri,
464494
.document = html_doc,
495+
.renderer = @ptrCast(&self.renderer),
465496
.cookie_jar = @ptrCast(&self.session.cookie_jar),
466497
.http_client = @ptrCast(self.session.http_client),
467498
});
@@ -753,6 +784,70 @@ pub const Page = struct {
753784
};
754785
};
755786

787+
// provide very poor abstration to the rest of the code. In theory, we can change
788+
// the FlatRendere to a different implementation, and it'll all just work.
789+
pub const Renderer = FlatRenderer;
790+
791+
// This "renderer" positions elements in a single row in an unspecified order.
792+
// The important thing is that elements have a consistent position/index within
793+
// that row, which can be turned into a rectangle.
794+
const FlatRenderer = struct {
795+
allocator: Allocator,
796+
797+
// key is a @ptrFromInt of the element
798+
// value is the index position
799+
positions: std.AutoHashMapUnmanaged(u64, u32),
800+
801+
// given an index, get the element
802+
elements: std.ArrayListUnmanaged(u64),
803+
804+
const Element = @import("../dom/element.zig").Element;
805+
806+
// we expect allocator to be an arena
807+
pub fn init(allocator: Allocator) FlatRenderer {
808+
return .{
809+
.elements = .{},
810+
.positions = .{},
811+
.allocator = allocator,
812+
};
813+
}
814+
815+
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
816+
var elements = &self.elements;
817+
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
818+
var x: u32 = gop.value_ptr.*;
819+
if (gop.found_existing == false) {
820+
try elements.append(self.allocator, @intFromPtr(e));
821+
x = @intCast(elements.items.len);
822+
gop.value_ptr.* = x;
823+
}
824+
825+
return .{
826+
.x = @floatFromInt(x),
827+
.y = 0.0,
828+
.width = 1.0,
829+
.height = 1.0,
830+
};
831+
}
832+
833+
pub fn width(self: *const FlatRenderer) u32 {
834+
return @intCast(self.elements.items.len);
835+
}
836+
837+
pub fn height(_: *const FlatRenderer) u32 {
838+
return 1;
839+
}
840+
841+
pub fn getElementAtPosition(self: *const FlatRenderer, x: u32, y: u32) ?*parser.Element {
842+
if (y > 1) {
843+
return null;
844+
}
845+
846+
const elements = self.elements.items;
847+
return if (x < elements.len) @ptrFromInt(elements[x]) else null;
848+
}
849+
};
850+
756851
const NoopInspector = struct {
757852
pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
758853
pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}

src/cdp/cdp.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub const CDP = CDPT(struct {
3737

3838
const SessionIdGen = Incrementing(u32, "SID");
3939
const TargetIdGen = Incrementing(u32, "TID");
40+
const LoaderIdGen = Incrementing(u32, "LID");
4041
const BrowserContextIdGen = Incrementing(u32, "BID");
4142

4243
// Generic so that we can inject mocks into it.
@@ -54,6 +55,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
5455
target_auto_attach: bool = false,
5556

5657
target_id_gen: TargetIdGen = .{},
58+
loader_id_gen: LoaderIdGen = .{},
5759
session_id_gen: SessionIdGen = .{},
5860
browser_context_id_gen: BrowserContextIdGen = .{},
5961

@@ -183,6 +185,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
183185
},
184186
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
185187
asUint("Fetch") => return @import("domains/fetch.zig").processMessage(command),
188+
asUint("Input") => return @import("domains/input.zig").processMessage(command),
186189
else => {},
187190
},
188191
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {

src/cdp/domains/input.zig

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
2+
//
3+
// Francis Bouvier <[email protected]>
4+
// Pierre Tachoire <[email protected]>
5+
//
6+
// This program is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// This program is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Affero General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Affero General Public License
17+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
19+
const std = @import("std");
20+
21+
pub fn processMessage(cmd: anytype) !void {
22+
const action = std.meta.stringToEnum(enum {
23+
dispatchMouseEvent,
24+
}, cmd.input.action) orelse return error.UnknownMethod;
25+
26+
switch (action) {
27+
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
28+
}
29+
}
30+
31+
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
32+
fn dispatchMouseEvent(cmd: anytype) !void {
33+
const params = (try cmd.params(struct {
34+
type: []const u8,
35+
x: u32,
36+
y: u32,
37+
})) orelse return error.InvalidParams;
38+
39+
try cmd.sendResult(null, .{});
40+
41+
if (std.ascii.eqlIgnoreCase(params.type, "mousePressed") == false) {
42+
return;
43+
}
44+
45+
const bc = cmd.browser_context orelse return;
46+
const page = bc.session.currentPage() orelse return;
47+
const click_result = (try page.click(cmd.arena, params.x, params.y)) orelse return;
48+
49+
switch (click_result) {
50+
.navigate => |uri| try clickNavigate(cmd, uri),
51+
}
52+
// result already sent
53+
}
54+
55+
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
56+
const bc = cmd.browser_context.?;
57+
58+
var url_buf: std.ArrayListUnmanaged(u8) = .{};
59+
try uri.writeToStream(.{
60+
.scheme = true,
61+
.authentication = true,
62+
.authority = true,
63+
.port = true,
64+
.path = true,
65+
.query = true,
66+
}, url_buf.writer(cmd.arena));
67+
const url = url_buf.items;
68+
69+
try cmd.sendEvent("Page.frameRequestedNavigation", .{
70+
.url = url,
71+
.frameId = bc.target_id.?,
72+
.reason = "anchorClick",
73+
.disposition = "currentTab",
74+
}, .{ .session_id = bc.session_id.? });
75+
76+
bc.session.removePage();
77+
_ = try bc.session.createPage(null);
78+
79+
try @import("page.zig").navigateToUrl(cmd, url, false);
80+
}

src/cdp/domains/page.zig

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ fn createIsolatedWorld(cmd: anytype) !void {
129129
}
130130

131131
fn navigate(cmd: anytype) !void {
132+
const params = (try cmd.params(struct {
133+
url: []const u8,
134+
// referrer: ?[]const u8 = null,
135+
// transitionType: ?[]const u8 = null, // TODO: enum
136+
// frameId: ?[]const u8 = null,
137+
// referrerPolicy: ?[]const u8 = null, // TODO: enum
138+
})) orelse return error.InvalidParams;
139+
140+
return navigateToUrl(cmd, params.url, true);
141+
}
142+
143+
pub fn navigateToUrl(cmd: anytype, url: []const u8, send_result: bool) !void {
132144
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
133145

134146
// didn't create?
@@ -140,20 +152,10 @@ fn navigate(cmd: anytype) !void {
140152
// if we have a target_id we have to have a page;
141153
std.debug.assert(bc.session.page != null);
142154

143-
const params = (try cmd.params(struct {
144-
url: []const u8,
145-
referrer: ?[]const u8 = null,
146-
transitionType: ?[]const u8 = null, // TODO: enum
147-
frameId: ?[]const u8 = null,
148-
referrerPolicy: ?[]const u8 = null, // TODO: enum
149-
})) orelse return error.InvalidParams;
150-
151155
// change state
152156
bc.reset();
153-
bc.url = params.url;
154-
155-
// TODO: hard coded ID
156-
bc.loader_id = "AF8667A203C5392DBE9AC290044AA4C2";
157+
bc.url = url;
158+
bc.loader_id = cmd.cdp.loader_id_gen.next();
157159

158160
const LifecycleEvent = struct {
159161
frameId: []const u8,
@@ -180,10 +182,12 @@ fn navigate(cmd: anytype) !void {
180182
}
181183

182184
// output
183-
try cmd.sendResult(.{
184-
.frameId = target_id,
185-
.loaderId = bc.loader_id,
186-
}, .{});
185+
if (send_result) {
186+
try cmd.sendResult(.{
187+
.frameId = target_id,
188+
.loaderId = bc.loader_id,
189+
}, .{});
190+
}
187191

188192
// TODO: at this point do we need async the following actions to be async?
189193

@@ -199,7 +203,7 @@ fn navigate(cmd: anytype) !void {
199203
);
200204

201205
var page = bc.session.currentPage().?;
202-
try page.navigate(params.url, aux_data);
206+
try page.navigate(url, aux_data);
203207

204208
// Events
205209

@@ -218,7 +222,7 @@ fn navigate(cmd: anytype) !void {
218222
.type = "Navigation",
219223
.frame = Frame{
220224
.id = target_id,
221-
.url = bc.url,
225+
.url = url,
222226
.securityOrigin = bc.security_origin,
223227
.secureContextType = bc.secure_context_type,
224228
.loaderId = bc.loader_id,

src/cdp/testing.zig

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,17 @@ const Page = struct {
106106
aux_data: []const u8 = "",
107107
doc: ?*parser.Document = null,
108108

109-
pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void {
110-
_ = self;
109+
pub fn navigate(_: *Page, url: []const u8, aux_data: []const u8) !void {
111110
_ = url;
112111
_ = aux_data;
113112
}
113+
114+
const ClickResult = @import("../browser/browser.zig").Page.ClickResult;
115+
pub fn click(_: *Page, _: Allocator, x: u32, y: u32) !?ClickResult {
116+
_ = x;
117+
_ = y;
118+
return null;
119+
}
114120
};
115121

116122
const Client = struct {

0 commit comments

Comments
 (0)