From effd07d8c00f2bc1749952c1f85f0ef36041da9b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Jun 2025 16:31:50 +0800 Subject: [PATCH 1/4] Add basic support for key events Support CDP's Input.dispatchKeyEvent and DOM key events. Currently only keydown is supported and expects every key to be a displayable character. It turns out that manipulating the DOM via key events isn't great because the behavior really depends on the cursor. So, to do this more accurately, we'd have to introduce some concept of a cursor. Personally, I don't think we'll run into many pages that are purposefully using keyboard events. But driver (puppeteer/playwright) scripts might be another issue. --- src/browser/dom/document.zig | 18 +++++--- src/browser/netsurf.zig | 59 ++++++++++++++++++++++++++ src/browser/page.zig | 78 +++++++++++++++++++++++++++++++++++ src/browser/xhr/form_data.zig | 8 ++-- src/cdp/domains/input.zig | 46 +++++++++++++++++++++ 5 files changed, 199 insertions(+), 10 deletions(-) diff --git a/src/browser/dom/document.zig b/src/browser/dom/document.zig index 569c1cfc..bc8dccbc 100644 --- a/src/browser/dom/document.zig +++ b/src/browser/dom/document.zig @@ -243,17 +243,23 @@ pub const Document = struct { return try TreeWalker.init(root, what_to_show, filter); } - pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion { - const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self))); - if (state.active_element) |ae| { - return try Element.toInterface(ae); + pub fn getActiveElement(self: *parser.Document, page: *Page) !?*parser.Element { + if (page.getNodeState(@alignCast(@ptrCast(self)))) |state| { + if (state.active_element) |ae| { + return ae; + } } if (try parser.documentHTMLBody(page.window.document)) |body| { - return try Element.toInterface(@alignCast(@ptrCast(body))); + return @alignCast(@ptrCast(body)); } - return get_documentElement(self); + return try parser.documentGetDocumentElement(self); + } + + pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion { + const ae = (try getActiveElement(self, page)) orelse return null; + return try Element.toInterface(ae); } // TODO: some elements can't be focused, like if they're disabled diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index 0dca5ba0..55fd361a 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -25,6 +25,7 @@ const c = @cImport({ @cInclude("events/event_target.h"); @cInclude("events/event.h"); @cInclude("events/mouse_event.h"); + @cInclude("events/keyboard_event.h"); @cInclude("utils/validate.h"); @cInclude("html/html_element.h"); @cInclude("html/html_document.h"); @@ -864,6 +865,59 @@ pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool { return eventDefaultPrevented(@ptrCast(evt)); } +// KeyboardEvent + +pub const KeyboardEvent = c.dom_keyboard_event; + +pub fn keyboardEventCreate() !*KeyboardEvent { + var evt: ?*KeyboardEvent = undefined; + const err = c._dom_keyboard_event_create(&evt); + try DOMErr(err); + return evt.?; +} + +pub fn keyboardEventDestroy(evt: *KeyboardEvent) void { + c._dom_keyboard_event_destroy(evt); +} + +const KeyboardEventOpts = struct { + key: []const u8, + code: []const u8, + bubbles: bool = false, + cancelable: bool = false, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + meta: bool = false, +}; + +pub fn keyboardEventInit(evt: *KeyboardEvent, typ: []const u8, opts: KeyboardEventOpts) !void { + const s = try strFromData(typ); + const err = c._dom_keyboard_event_init( + evt, + s, + opts.bubbles, + opts.cancelable, + null, // dom_abstract_view* ? + try strFromData(opts.key), + try strFromData(opts.code), + 0, // location 0 == standard + opts.ctrl, + opts.shift, + opts.alt, + opts.meta, + false, // repease + false, // is_composiom + ); + try DOMErr(err); +} + +pub fn keyboardEventGetKey(evt: *KeyboardEvent) ![]const u8 { + var s: ?*String = undefined; + _ = c._dom_keyboard_event_get_key(evt, &s); + return strToData(s.?); +} + // NodeType pub const NodeType = enum(u4) { @@ -2393,6 +2447,11 @@ pub fn textareaGetValue(textarea: *TextArea) ![]const u8 { return strToData(s); } +pub fn textareaSetValue(textarea: *TextArea, value: []const u8) !void { + const err = c.dom_html_text_area_element_set_value(textarea, try strFromData(value)); + try DOMErr(err); +} + // Select pub fn selectGetOptions(select: *Select) !*OptionCollection { var collection: ?*OptionCollection = null; diff --git a/src/browser/page.zig b/src/browser/page.zig index e69d9d37..0169c86a 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -80,6 +80,7 @@ pub const Page = struct { microtask_node: Loop.CallbackNode, + keydown_event_node: parser.EventNode, window_clicked_event_node: parser.EventNode, // Our JavaScript context for this specific page. This is what we use to @@ -112,6 +113,7 @@ pub const Page = struct { .state_pool = &browser.state_pool, .cookie_jar = &session.cookie_jar, .microtask_node = .{ .func = microtaskCallback }, + .keydown_event_node = .{ .func = keydownCallback }, .window_clicked_event_node = .{ .func = windowClicked }, .request_factory = browser.http_client.requestFactory(.{ .notification = browser.notification, @@ -307,6 +309,12 @@ pub const Page = struct { &self.window_clicked_event_node, false, ); + _ = try parser.eventTargetAddEventListener( + parser.toEventTarget(parser.Element, document_element), + "keydown", + &self.keydown_event_node, + false, + ); // https://html.spec.whatwg.org/#read-html @@ -595,6 +603,76 @@ pub const Page = struct { } } + pub const KeyboardEvent = struct { + type: Type, + key: []const u8, + code: []const u8, + alt: bool, + ctrl: bool, + meta: bool, + shift: bool, + + const Type = enum { + keydown, + }; + }; + + pub fn keyboardEvent(self: *Page, kbe: KeyboardEvent) !void { + if (kbe.type != .keydown) { + return; + } + + const Document = @import("dom/document.zig").Document; + const element = (try Document.getActiveElement(@ptrCast(self.window.document), self)) orelse return; + + const event = try parser.keyboardEventCreate(); + defer parser.keyboardEventDestroy(event); + try parser.keyboardEventInit(event, "keydown", .{ + .bubbles = true, + .cancelable = true, + .key = kbe.key, + .code = kbe.code, + .alt = kbe.alt, + .ctrl = kbe.ctrl, + .meta = kbe.meta, + .shift = kbe.shift, + }); + _ = try parser.elementDispatchEvent(element, @ptrCast(event)); + } + + fn keydownCallback(node: *parser.EventNode, event: *parser.Event) void { + const self: *Page = @fieldParentPtr("keydown_event_node", node); + self._keydownCallback(event) catch |err| { + log.err(.browser, "keydown handler error", .{ .err = err }); + }; + } + + fn _keydownCallback(page: *Page, event: *parser.Event) !void { + const kbe: *parser.KeyboardEvent = @ptrCast(event); + const target = (try parser.eventTarget(event)) orelse return; + const node = parser.eventTargetToNode(target); + const tag = (try parser.nodeHTMLGetTagType(node)) orelse return; + switch (tag) { + .input => { + const element: *parser.Element = @ptrCast(node); + const input_type = (try parser.elementGetAttribute(element, "type")) orelse "text"; + if (std.mem.eql(u8, input_type, "text")) { + const value = try parser.inputGetValue(@ptrCast(element)); + const new_key = try parser.keyboardEventGetKey(kbe); + const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key }); + try parser.inputSetValue(@ptrCast(element), new_value); + } + }, + .textarea => { + const value = try parser.textareaGetValue(@ptrCast(node)); + const new_key = try parser.keyboardEventGetKey(kbe); + const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key }); + try parser.textareaSetValue(@ptrCast(node), new_value); + }, + else => {}, + } + } + // As such we schedule the function to be called as soon as possible. // The page.arena is safe to use here, but the transfer_arena exists // specifically for this type of lifetime. diff --git a/src/browser/xhr/form_data.zig b/src/browser/xhr/form_data.zig index 56e4131b..c5e3f278 100644 --- a/src/browser/xhr/form_data.zig +++ b/src/browser/xhr/form_data.zig @@ -162,7 +162,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page } submitter_included = true; } - const value = (try parser.elementGetAttribute(element, "value")) orelse ""; + const value = try parser.inputGetValue(@ptrCast(element)); try entries.appendOwned(arena, name, value); }, .select => { @@ -189,11 +189,11 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page } if (submitter_included == false) { - if (submitter_) |submitter| { + if (submitter_name_) |submitter_name| { // this can happen if the submitter is outside the form, but associated // with the form via a form=ID attribute - const value = (try parser.elementGetAttribute(@ptrCast(submitter), "value")) orelse ""; - try entries.appendOwned(arena, submitter_name_.?, value); + const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse ""; + try entries.appendOwned(arena, submitter_name, value); } } diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig index 170098ff..0a25584a 100644 --- a/src/cdp/domains/input.zig +++ b/src/cdp/domains/input.zig @@ -21,14 +21,60 @@ const Page = @import("../../browser/page.zig").Page; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { + dispatchKeyEvent, dispatchMouseEvent, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { + .dispatchKeyEvent => return dispatchKeyEvent(cmd), .dispatchMouseEvent => return dispatchMouseEvent(cmd), } } +// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent +fn dispatchKeyEvent(cmd: anytype) !void { + const params = (try cmd.params(struct { + type: Type, + key: []const u8, + code: []const u8, + modifiers: u4, + // Many optional parameters are not implemented yet, see documentation url. + + const Type = enum { + keyDown, + keyUp, + rawKeyDown, + char, + }; + })) orelse return error.InvalidParams; + + try cmd.sendResult(null, .{}); + + // quickly ignore types we know we don't handle + switch (params.type) { + .keyUp, .rawKeyDown, .char => return, + .keyDown => {}, + } + + const bc = cmd.browser_context orelse return; + const page = bc.session.currentPage() orelse return; + + const keyboard_event = Page.KeyboardEvent{ + .key = params.key, + .code = params.code, + .type = switch (params.type) { + .keyDown => .keydown, + else => unreachable, + }, + .alt = params.modifiers & 1 == 1, + .ctrl = params.modifiers & 2 == 2, + .meta = params.modifiers & 4 == 4, + .shift = params.modifiers & 8 == 8, + }; + try page.keyboardEvent(keyboard_event); + // result already sent +} + // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent fn dispatchMouseEvent(cmd: anytype) !void { const params = (try cmd.params(struct { From 3a4bd0002076084839909316e57023d11b9428cd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Jun 2025 19:37:26 +0800 Subject: [PATCH 2/4] Ignore dead keys Submit form on "Enter" within text input. Convert "Enter" to "\n" in textarea. --- src/browser/page.zig | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/browser/page.zig b/src/browser/page.zig index 0169c86a..05d65304 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -647,26 +647,38 @@ pub const Page = struct { }; } - fn _keydownCallback(page: *Page, event: *parser.Event) !void { - const kbe: *parser.KeyboardEvent = @ptrCast(event); + fn _keydownCallback(self: *Page, event: *parser.Event) !void { const target = (try parser.eventTarget(event)) orelse return; const node = parser.eventTargetToNode(target); const tag = (try parser.nodeHTMLGetTagType(node)) orelse return; + + const kbe: *parser.KeyboardEvent = @ptrCast(event); + var new_key = try parser.keyboardEventGetKey(kbe); + if (std.mem.eql(u8, new_key, "Dead")) { + return; + } + switch (tag) { .input => { const element: *parser.Element = @ptrCast(node); const input_type = (try parser.elementGetAttribute(element, "type")) orelse "text"; if (std.mem.eql(u8, input_type, "text")) { + if (std.mem.eql(u8, new_key, "Enter")) { + const form = (try self.formForElement(element)) orelse return; + return self.submitForm(@ptrCast(form), null); + } + const value = try parser.inputGetValue(@ptrCast(element)); - const new_key = try parser.keyboardEventGetKey(kbe); - const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key }); + const new_value = try std.mem.concat(self.arena, u8, &.{ value, new_key }); try parser.inputSetValue(@ptrCast(element), new_value); } }, .textarea => { const value = try parser.textareaGetValue(@ptrCast(node)); - const new_key = try parser.keyboardEventGetKey(kbe); - const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key }); + if (std.mem.eql(u8, new_key, "Enter")) { + new_key = "\n"; + } + const new_value = try std.mem.concat(self.arena, u8, &.{ value, new_key }); try parser.textareaSetValue(@ptrCast(node), new_value); }, else => {}, @@ -734,7 +746,6 @@ pub const Page = struct { } else { action = try URL.concatQueryString(transfer_arena, action, buf.items); } - try self.navigateFromWebAPI(action, opts); } From 34b49498c9671072e32d04259be26fe7948917a9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Jun 2025 20:37:49 +0800 Subject: [PATCH 3/4] Making sure that the optional parameters have defaults Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com> --- src/cdp/domains/input.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig index 0a25584a..d81fb1c8 100644 --- a/src/cdp/domains/input.zig +++ b/src/cdp/domains/input.zig @@ -35,9 +35,9 @@ pub fn processMessage(cmd: anytype) !void { fn dispatchKeyEvent(cmd: anytype) !void { const params = (try cmd.params(struct { type: Type, - key: []const u8, - code: []const u8, - modifiers: u4, + key: []const u8 = "", + code: []const u8 = "", + modifiers: u4 = 0, // Many optional parameters are not implemented yet, see documentation url. const Type = enum { From cf39bdc7f74b5064730a446483cce2991620b012 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 11 Jun 2025 20:59:12 +0800 Subject: [PATCH 4/4] use inputGetType and add buttonGetType --- src/browser/netsurf.zig | 8 ++++++++ src/browser/page.zig | 6 +++--- src/browser/xhr/form_data.zig | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index 55fd361a..03db92e2 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -2834,3 +2834,11 @@ pub fn inputSetValue(input: *Input, value: []const u8) !void { const err = c.dom_html_input_element_set_value(input, try strFromData(value)); try DOMErr(err); } + +pub fn buttonGetType(button: *Button) ![]const u8 { + var s_: ?*String = null; + const err = c.dom_html_button_element_get_type(button, &s_); + try DOMErr(err); + const s = s_ orelse return "button"; + return strToData(s); +} diff --git a/src/browser/page.zig b/src/browser/page.zig index 05d65304..2a652c88 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -582,14 +582,14 @@ pub const Page = struct { }, .input => { const element: *parser.Element = @ptrCast(node); - const input_type = (try parser.elementGetAttribute(element, "type")) orelse return; + const input_type = try parser.inputGetType(@ptrCast(element)); if (std.ascii.eqlIgnoreCase(input_type, "submit")) { return self.elementSubmitForm(element); } }, .button => { const element: *parser.Element = @ptrCast(node); - const button_type = (try parser.elementGetAttribute(element, "type")) orelse return; + const button_type = try parser.buttonGetType(@ptrCast(element)); if (std.ascii.eqlIgnoreCase(button_type, "submit")) { return self.elementSubmitForm(element); } @@ -661,7 +661,7 @@ pub const Page = struct { switch (tag) { .input => { const element: *parser.Element = @ptrCast(node); - const input_type = (try parser.elementGetAttribute(element, "type")) orelse "text"; + const input_type = try parser.inputGetType(@ptrCast(element)); if (std.mem.eql(u8, input_type, "text")) { if (std.mem.eql(u8, new_key, "Enter")) { const form = (try self.formForElement(element)) orelse return; diff --git a/src/browser/xhr/form_data.zig b/src/browser/xhr/form_data.zig index c5e3f278..485ef647 100644 --- a/src/browser/xhr/form_data.zig +++ b/src/browser/xhr/form_data.zig @@ -137,7 +137,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(element))); switch (tag) { .input => { - const tpe = try parser.elementGetAttribute(element, "type") orelse ""; + const tpe = try parser.inputGetType(@ptrCast(element)); if (std.ascii.eqlIgnoreCase(tpe, "image")) { if (submitter_name_) |submitter_name| { if (std.mem.eql(u8, submitter_name, name)) { @@ -249,7 +249,7 @@ fn getSubmitterName(submitter_: ?*parser.ElementHTML) !?[]const u8 { switch (tag) { .button => return name, .input => { - const tpe = (try parser.elementGetAttribute(element, "type")) orelse ""; + const tpe = try parser.inputGetType(@ptrCast(element)); // only an image type can be a sumbitter if (std.ascii.eqlIgnoreCase(tpe, "image") or std.ascii.eqlIgnoreCase(tpe, "submit")) { return name;