Merge pull request #501 from karlseguin/renderer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled

Add a dumb renderer to get coordinates
This commit is contained in:
Pierre Tachoire
2025-04-08 17:12:49 +02:00
committed by GitHub
11 changed files with 378 additions and 31 deletions

View File

@@ -298,10 +298,14 @@ pub const Page = struct {
// current_script could by fetch module to resolve module's url to fetch.
current_script: ?*const Script = null,
renderer: FlatRenderer,
fn init(session: *Session) Page {
const arena = session.browser.page_arena.allocator();
return .{
.arena = arena,
.session = session,
.arena = session.browser.page_arena.allocator(),
.renderer = FlatRenderer.init(arena),
};
}
@@ -423,6 +427,53 @@ pub const Page = struct {
}
}
pub const ClickResult = union(enum) {
navigate: std.Uri,
};
pub const MouseEvent = struct {
x: i32,
y: i32,
type: Type,
const Type = enum {
pressed,
released,
};
};
pub fn mouseEvent(self: *Page, allocator: Allocator, me: MouseEvent) !?ClickResult {
if (me.type != .pressed) {
return null;
}
const element = self.renderer.getElementAtPosition(me.x, me.y) orelse return null;
const event = try parser.mouseEventCreate();
defer parser.mouseEventDestroy(event);
try parser.mouseEventInit(event, "click", .{
.bubbles = true,
.cancelable = true,
.x = me.x,
.y = me.y,
});
_ = try parser.elementDispatchEvent(element, @ptrCast(event));
if ((try parser.mouseEventDefaultPrevented(event)) == true) {
return null;
}
const node = parser.elementToNode(element);
const tag = try parser.nodeName(node);
if (std.ascii.eqlIgnoreCase(tag, "a")) {
const href = (try parser.elementGetAttribute(element, "href")) orelse return null;
var buf = try allocator.alloc(u8, 1024);
return .{ .navigate = try std.Uri.resolve_inplace(self.uri, href, &buf) };
}
return null;
}
// https://html.spec.whatwg.org/#read-html
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8, aux_data: ?[]const u8) !void {
const arena = self.arena;
@@ -462,6 +513,7 @@ pub const Page = struct {
try session.env.setUserContext(.{
.uri = self.uri,
.document = html_doc,
.renderer = @ptrCast(&self.renderer),
.cookie_jar = @ptrCast(&self.session.cookie_jar),
.http_client = @ptrCast(self.session.http_client),
});
@@ -753,6 +805,70 @@ pub const Page = struct {
};
};
// provide very poor abstration to the rest of the code. In theory, we can change
// the FlatRendere to a different implementation, and it'll all just work.
pub const Renderer = FlatRenderer;
// This "renderer" positions elements in a single row in an unspecified order.
// The important thing is that elements have a consistent position/index within
// that row, which can be turned into a rectangle.
const FlatRenderer = struct {
allocator: Allocator,
// key is a @ptrFromInt of the element
// value is the index position
positions: std.AutoHashMapUnmanaged(u64, u32),
// given an index, get the element
elements: std.ArrayListUnmanaged(u64),
const Element = @import("../dom/element.zig").Element;
// we expect allocator to be an arena
pub fn init(allocator: Allocator) FlatRenderer {
return .{
.elements = .{},
.positions = .{},
.allocator = allocator,
};
}
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
var elements = &self.elements;
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
var x: u32 = gop.value_ptr.*;
if (gop.found_existing == false) {
try elements.append(self.allocator, @intFromPtr(e));
x = @intCast(elements.items.len);
gop.value_ptr.* = x;
}
return .{
.x = @floatFromInt(x),
.y = 0.0,
.width = 1.0,
.height = 1.0,
};
}
pub fn width(self: *const FlatRenderer) u32 {
return @intCast(self.elements.items.len);
}
pub fn height(_: *const FlatRenderer) u32 {
return 1;
}
pub fn getElementAtPosition(self: *const FlatRenderer, x: i32, y: i32) ?*parser.Element {
if (y != 1 or x < 0) {
return null;
}
const elements = self.elements.items;
return if (x < elements.len) @ptrFromInt(elements[@intCast(x)]) else null;
}
};
const NoopInspector = struct {
pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}

View File

@@ -37,6 +37,7 @@ pub const CDP = CDPT(struct {
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID");
const LoaderIdGen = Incrementing(u32, "LID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
@@ -54,6 +55,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
target_auto_attach: bool = false,
target_id_gen: TargetIdGen = .{},
loader_id_gen: LoaderIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
@@ -183,6 +185,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint("Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint("Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
@@ -281,8 +284,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
// we should reject it.
session_id: ?[]const u8,
// State
url: []const u8,
loader_id: []const u8,
security_origin: []const u8,
page_life_cycle_events: bool,
@@ -303,7 +304,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
.cdp = cdp,
.target_id = null,
.session_id = null,
.url = URL_BASE,
.security_origin = URL_BASE,
.secure_context_type = "Secure", // TODO = enum
.loader_id = LOADER_ID,
@@ -333,6 +333,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
};
}
pub fn getURL(self: *const Self) ?[]const u8 {
const page = self.session.currentPage() orelse return null;
return page.rawuri;
}
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
if (std.log.defaultLogEnabled(.debug)) {
// msg should be {"id":<id>,...

100
src/cdp/domains/input.zig Normal file
View File

@@ -0,0 +1,100 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("../../browser/browser.zig").Page;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchMouseEvent,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
fn dispatchMouseEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {
x: i32,
y: i32,
type: Type,
const Type = enum {
mousePressed,
mouseReleased,
mouseMoved,
mouseWheel,
};
})) orelse return error.InvalidParams;
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.mouseMoved, .mouseWheel => return,
else => {},
}
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const mouse_event = Page.MouseEvent{
.x = params.x,
.y = params.y,
.type = switch (params.type) {
.mousePressed => .pressed,
.mouseReleased => .released,
else => unreachable,
},
};
const click_result = (try page.mouseEvent(cmd.arena, mouse_event)) orelse return;
switch (click_result) {
.navigate => |uri| try clickNavigate(cmd, uri),
}
// result already sent
}
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
const bc = cmd.browser_context.?;
var url_buf: std.ArrayListUnmanaged(u8) = .{};
try uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.port = true,
.path = true,
.query = true,
}, url_buf.writer(cmd.arena));
const url = url_buf.items;
try cmd.sendEvent("Page.frameRequestedNavigation", .{
.url = url,
.frameId = bc.target_id.?,
.reason = "anchorClick",
.disposition = "currentTab",
}, .{ .session_id = bc.session_id.? });
bc.session.removePage();
_ = try bc.session.createPage(null);
try @import("page.zig").navigateToUrl(cmd, url, false);
}

View File

@@ -61,10 +61,10 @@ fn getFrameTree(cmd: anytype) !void {
return cmd.sendResult(.{
.frameTree = .{
.frame = Frame{
.url = bc.url,
.id = target_id,
.loaderId = bc.loader_id,
.securityOrigin = bc.security_origin,
.url = bc.getURL() orelse "about:blank",
.secureContextType = bc.secure_context_type,
},
},
@@ -129,6 +129,18 @@ fn createIsolatedWorld(cmd: anytype) !void {
}
fn navigate(cmd: anytype) !void {
const params = (try cmd.params(struct {
url: []const u8,
// referrer: ?[]const u8 = null,
// transitionType: ?[]const u8 = null, // TODO: enum
// frameId: ?[]const u8 = null,
// referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
return navigateToUrl(cmd, params.url, true);
}
pub fn navigateToUrl(cmd: anytype, url: []const u8, send_result: bool) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// didn't create?
@@ -140,20 +152,9 @@ fn navigate(cmd: anytype) !void {
// if we have a target_id we have to have a page;
std.debug.assert(bc.session.page != null);
const params = (try cmd.params(struct {
url: []const u8,
referrer: ?[]const u8 = null,
transitionType: ?[]const u8 = null, // TODO: enum
frameId: ?[]const u8 = null,
referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
// change state
bc.reset();
bc.url = params.url;
// TODO: hard coded ID
bc.loader_id = "AF8667A203C5392DBE9AC290044AA4C2";
bc.loader_id = cmd.cdp.loader_id_gen.next();
const LifecycleEvent = struct {
frameId: []const u8,
@@ -180,10 +181,12 @@ fn navigate(cmd: anytype) !void {
}
// output
try cmd.sendResult(.{
.frameId = target_id,
.loaderId = bc.loader_id,
}, .{});
if (send_result) {
try cmd.sendResult(.{
.frameId = target_id,
.loaderId = bc.loader_id,
}, .{});
}
// TODO: at this point do we need async the following actions to be async?
@@ -199,7 +202,7 @@ fn navigate(cmd: anytype) !void {
);
var page = bc.session.currentPage().?;
try page.navigate(params.url, aux_data);
try page.navigate(url, aux_data);
// Events
@@ -218,7 +221,7 @@ fn navigate(cmd: anytype) !void {
.type = "Navigation",
.frame = Frame{
.id = target_id,
.url = bc.url,
.url = url,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
.loaderId = bc.loader_id,
@@ -281,7 +284,7 @@ test "cdp.page: getFrameTree" {
.frame = .{
.id = "TID-3",
.loaderId = bc.loader_id,
.url = bc.url,
.url = "about:blank",
.domainAndRegistry = "",
.securityOrigin = bc.security_origin,
.mimeType = "text/html",

View File

@@ -132,7 +132,6 @@ fn createTarget(cmd: anytype) !void {
_ = try bc.session.createPage(aux_data);
// change CDP state
bc.url = "about:blank";
bc.security_origin = "://";
bc.secure_context_type = "InsecureScheme";
bc.loader_id = LOADER_ID;
@@ -142,11 +141,11 @@ fn createTarget(cmd: anytype) !void {
// has been enabled?
try cmd.sendEvent("Target.targetCreated", .{
.targetInfo = TargetInfo{
.url = bc.url,
.attached = false,
.targetId = target_id,
.title = "about:blank",
.browserContextId = bc.id,
.attached = false,
.url = "about:blank",
},
}, .{});

View File

@@ -85,6 +85,7 @@ const Session = struct {
return error.MockBrowserPageAlreadyExists;
}
self.page = .{
.rawuri = "",
.session = self,
.aux_data = try self.arena.dupe(u8, aux_data orelse ""),
};
@@ -103,14 +104,20 @@ const Session = struct {
const Page = struct {
session: *Session,
rawuri: []const u8,
aux_data: []const u8 = "",
doc: ?*parser.Document = null,
pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void {
_ = self;
pub fn navigate(_: *Page, url: []const u8, aux_data: []const u8) !void {
_ = url;
_ = aux_data;
}
const MouseEvent = @import("../browser/browser.zig").Page.MouseEvent;
const ClickResult = @import("../browser/browser.zig").Page.ClickResult;
pub fn mouseEvent(_: *Page, _: Allocator, _: MouseEvent) !?ClickResult {
return null;
}
};
const Client = struct {

View File

@@ -33,6 +33,7 @@ const Node = @import("node.zig").Node;
const Walker = @import("walker.zig").WalkerDepthFirst;
const NodeList = @import("nodelist.zig").NodeList;
const HTMLElem = @import("../html/elements.zig");
const UserContext = @import("../user_context.zig").UserContext;
pub const Union = @import("../html/elements.zig").Union;
const DOMException = @import("exceptions.zig").DOMException;
@@ -43,6 +44,13 @@ pub const Element = struct {
pub const prototype = *Node;
pub const mem_guarantied = true;
pub const DOMRect = struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
pub fn toInterface(e: *parser.Element) !Union {
return try HTMLElem.toInterface(Union, e);
}
@@ -339,6 +347,18 @@ pub const Element = struct {
return Node.replaceChildren(parser.elementToNode(self), nodes);
}
pub fn _getBoundingClientRect(self: *parser.Element, user_context: UserContext) !DOMRect {
return user_context.renderer.getRect(self);
}
pub fn get_clientWidth(_: *parser.Element, user_context: UserContext) u32 {
return user_context.renderer.width();
}
pub fn get_clientHeight(_: *parser.Element, user_context: UserContext) u32 {
return user_context.renderer.height();
}
pub fn deinit(_: *parser.Element, _: std.mem.Allocator) void {}
};
@@ -484,5 +504,33 @@ pub fn testExecFn(
var outerHTML = [_]Case{
.{ .src = "document.getElementById('para').outerHTML", .ex = "<p id=\"para\"> And</p>" },
};
var getBoundingClientRect = [_]Case{
.{ .src = "document.getElementById('para').clientWidth", .ex = "0" },
.{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
.{ .src = "let r1 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r1.x", .ex = "1" },
.{ .src = "r1.y", .ex = "0" },
.{ .src = "r1.width", .ex = "1" },
.{ .src = "r1.height", .ex = "1" },
.{ .src = "let r2 = document.getElementById('content').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r2.x", .ex = "2" },
.{ .src = "r2.y", .ex = "0" },
.{ .src = "r2.width", .ex = "1" },
.{ .src = "r2.height", .ex = "1" },
.{ .src = "let r3 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r3.x", .ex = "1" },
.{ .src = "r3.y", .ex = "0" },
.{ .src = "r3.width", .ex = "1" },
.{ .src = "r3.height", .ex = "1" },
.{ .src = "document.getElementById('para').clientWidth", .ex = "2" },
.{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
};
try checkCases(js_env, &getBoundingClientRect);
try checkCases(js_env, &outerHTML);
}

View File

@@ -25,6 +25,7 @@ const pretty = @import("pretty");
const parser = @import("netsurf");
const apiweb = @import("apiweb.zig");
const browser = @import("browser/browser.zig");
const Window = @import("html/window.zig").Window;
const xhr = @import("xhr/xhr.zig");
const storage = @import("storage/storage.zig");
@@ -100,9 +101,14 @@ fn testExecFn(
var cookie_jar = storage.CookieJar.init(alloc);
defer cookie_jar.deinit();
var renderer = browser.Renderer.init(alloc);
defer renderer.elements.deinit(alloc);
defer renderer.positions.deinit(alloc);
try js_env.setUserContext(.{
.uri = try std.Uri.parse(url),
.document = doc,
.renderer = &renderer,
.cookie_jar = &cookie_jar,
.http_client = &http_client,
});

View File

@@ -24,6 +24,7 @@ const c = @cImport({
@cInclude("dom/bindings/hubbub/parser.h");
@cInclude("events/event_target.h");
@cInclude("events/event.h");
@cInclude("events/mouse_event.h");
});
const mimalloc = @import("mimalloc");
@@ -801,6 +802,11 @@ pub fn eventTargetDispatchEvent(et: *EventTarget, event: *Event) !bool {
return res;
}
pub fn elementDispatchEvent(element: *Element, event: *Event) !bool {
const et: *EventTarget = toEventTarget(Element, element);
return eventTargetDispatchEvent(et, @ptrCast(event));
}
pub fn eventTargetTBaseFieldName(comptime T: type) ?[]const u8 {
std.debug.assert(@inComptime());
switch (@typeInfo(T)) {
@@ -860,6 +866,61 @@ pub const EventTargetTBase = extern struct {
}
};
// MouseEvent
pub const MouseEvent = c.dom_mouse_event;
pub fn mouseEventCreate() !*MouseEvent {
var evt: ?*MouseEvent = undefined;
const err = c._dom_mouse_event_create(&evt);
try DOMErr(err);
return evt.?;
}
pub fn mouseEventDestroy(evt: *MouseEvent) void {
c._dom_mouse_event_destroy(evt);
}
const MouseEventOpts = struct {
x: i32,
y: i32,
bubbles: bool = false,
cancelable: bool = false,
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
meta: bool = false,
button: u16 = 0,
click_count: u16 = 1,
};
pub fn mouseEventInit(evt: *MouseEvent, typ: []const u8, opts: MouseEventOpts) !void {
const s = try strFromData(typ);
const err = c._dom_mouse_event_init(
evt,
s,
opts.bubbles,
opts.cancelable,
null, // dom_abstract_view* ?
opts.click_count, // details
opts.x, // screen_x
opts.y, // screen_y
opts.x, // client_x
opts.y, // client_y
opts.ctrl,
opts.alt,
opts.shift,
opts.meta,
opts.button,
null, // related target
);
try DOMErr(err);
}
pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool {
return eventDefaultPrevented(@ptrCast(evt));
}
// NodeType
pub const NodeType = enum(u4) {

View File

@@ -2,10 +2,12 @@ const std = @import("std");
const parser = @import("netsurf");
const storage = @import("storage/storage.zig");
const Client = @import("http/client.zig").Client;
const Renderer = @import("browser/browser.zig").Renderer;
pub const UserContext = struct {
http_client: *Client,
uri: std.Uri,
http_client: *Client,
document: *parser.DocumentHTML,
cookie_jar: *storage.CookieJar,
renderer: *Renderer,
};