From 71b3c93521e9a3c714eaaf09ff4694b9578c83dc Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 13 Aug 2026 11:18:33 -0400 Subject: [PATCH] Add find shortcut in browser. --- README.md | 30 +++++- src/Browser.zig | 269 ++++++++++++++++++++++++++++++++++++++++++++++-- src/Pane.zig | 10 ++ src/View.zig | 8 ++ src/Window.zig | 22 +++- src/style.css | 20 ++++ src/webkit.zig | 92 +++++++++++++++++ 7 files changed, 437 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7339d4e..d704e7f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ View.zig one tab's content: its panes, their layout, and drag handling Layout.zig the split tree: nodes, rearranging, GtkPaned materialization Pane.zig content plus its header, drag source, and drop target Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling -Browser.zig WebKitWebView plus a back/forward/reload/address bar +Browser.zig WebKitWebView plus a nav/address bar and a find bar webkit.zig hand-written bindings for the WebKitGTK calls we make Layouts.zig saved tab templates: model, JSON on disk, {{substitution}} OpenLayoutDialog.zig prompts for a layout's parameters @@ -369,7 +369,8 @@ in principle, but a terminal grid is small. - **Web panes**, on WebKitGTK, sitting in the split tree beside terminals and dragging around exactly like they do: back/forward/reload, an address bar that takes a URL or falls back to a search, a load-progress indicator in the - entry, and the page title feeding the pane header and tab label + entry, and the page title feeding the pane header and tab label, plus + find-in-page on `Ctrl+F`. See [Find in page](#find-in-page) - **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories and scripts — opened in one go, parameterised by `{{name}}`, authored by arranging a tab and saving it. See [Layouts](#layouts) @@ -404,6 +405,7 @@ in principle, but a terminal grid is small. | `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | | `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | | `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back | +| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) | | `Ctrl+,` | settings | | `Ctrl+PageUp/PageDown` | previous / next tab | | `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | @@ -441,6 +443,26 @@ that zoom is hiding. Closing the zoomed pane leaves zoom; a *different* pane closing — a background shell exiting, say — does not, since what you are looking at is still there. +## Find in page + +`Ctrl+F` in a web pane opens a find bar under its address bar. Typing searches +as you go — case-insensitively, wrapping at the end of the document — and the +bar reports how many matches there are, or says there are none and reddens the +box. `Enter` steps to the next match, `Shift+Enter` to the previous, and the +two arrow buttons do the same. `Escape` or the × closes the bar, which is also +what clears the highlighting. + +The chord is the browser one rather than this app's usual `Ctrl+Shift`, and it +is claimed only while a web pane has focus. In a terminal `Ctrl+F` is an +ordinary control character that the program running there is waiting for — +forward-a-character in a readline prompt, the prefix key in tmux — so the key +is left alone there. + +WebKit does the searching, and it will only tell you the total number of +matches, not which one you are on; the count stops at a thousand, on the +grounds that a page with more matches than that is one where the exact number +tells you nothing. + ## Tab settings Right-clicking a tab row opens a small menu: **Rename**, which is the same @@ -682,8 +704,8 @@ This is a proof of concept, and the following are deliberately absent: look right. - **Moving panes between tabs.** Panes can only be rearranged within their own view. -- **Browser furniture.** Web panes get navigation and an address bar and - nothing else: no bookmarks, history, downloads, devtools, or find-in-page, +- **Browser furniture.** Web panes get navigation, an address bar and + find-in-page, and nothing else: no bookmarks, history, downloads or devtools, and each pane uses WebKit's default context, so nothing is persisted between runs. Links that ask for a new window are ignored rather than opening a pane. - **Restoring a session.** [Startup tabs](#startup-tabs) reopen the diff --git a/src/Browser.zig b/src/Browser.zig index 2e69d80..cb415cf 100644 --- a/src/Browser.zig +++ b/src/Browser.zig @@ -10,6 +10,7 @@ //! one through the same three signals. const std = @import("std"); +const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); @@ -26,6 +27,18 @@ const search_prefix = "https://duckduckgo.com/?q="; /// search query that triples in length under percent-encoding. const url_max = 4096; +/// How find-in-page matches: the same case-insensitive, wrapping search every +/// browser's Ctrl+F does. +const find_options: webkit.FindController.Options = .{ + .case_insensitive = true, + .wrap_around = true, +}; + +/// How far WebKit is asked to count matches. A page with more than this many +/// is one where the exact number tells you nothing, and counting stops there +/// rather than tallying every occurrence in a huge document. +const find_max_matches = 1000; + alloc: std.mem.Allocator, /// Vertical box: nav bar on top, web view filling the rest. @@ -40,6 +53,15 @@ forward: *gtk.Button, /// Toggles between reload and stop depending on whether a load is running. reload: *gtk.Button, +/// Find-in-page bar, between the nav bar and the page. Hidden until Ctrl+F +/// asks for it, since a pane that isn't being searched shouldn't spend a row +/// of its height saying so. +find_bar: *gtk.Box, +find_entry: *gtk.SearchEntry, + +/// Match count, or the news that there weren't any. +find_status: *gtk.Label, + /// Scratch for building the URI handed to WebKit. Held here rather than on the /// stack because it is larger than a callback frame wants to carry. url_buf: [url_max]u8 = undefined, @@ -71,6 +93,9 @@ pub fn create( .back = gtk.Button.newFromIconName("go-previous-symbolic"), .forward = gtk.Button.newFromIconName("go-next-symbolic"), .reload = gtk.Button.newFromIconName("view-refresh-symbolic"), + .find_bar = gtk.Box.new(.horizontal, 2), + .find_entry = gtk.SearchEntry.new(), + .find_status = gtk.Label.new(""), .on_title = cbs.on_title, .on_exit = cbs.on_exit, .on_focus = cbs.on_focus, @@ -78,6 +103,7 @@ pub fn create( }; self.box.append(self.buildNav()); + self.box.append(self.buildFind()); const view_widget = self.view.as(gtk.Widget); view_widget.setHexpand(1); @@ -96,6 +122,13 @@ pub fn create( // shell exiting closes a terminal's. self.view.connectSignal("close", *Browser, &onClose, self); + // Searching is asynchronous: every outcome arrives on one of these rather + // than from the call that started it. + const find = self.view.getFindController(); + find.connectSignal("counted-matches", *Browser, &onCountedMatches, self); + find.connectSignal("found-text", *Browser, &onFoundText, self); + find.connectSignal("failed-to-find-text", *Browser, &onFindFailed, self); + // Fires for focus landing anywhere inside, so clicking the address bar // marks the pane active just as clicking the page does. const focus = gtk.EventControllerFocus.new(); @@ -123,15 +156,22 @@ pub fn destroy(self: *Browser) void { // down a page makes WebKit emit property changes on the way out, so every // handler bound to `self` has to go before `self` does, or those changes // land on freed memory. - _ = gobject.signalHandlersDisconnectMatched( + for ([_]*gobject.Object{ self.view.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); + // The find controller is a second object with the same lifetime and + // the same hazard: it belongs to the view, not to us. + self.view.getFindController().as(gobject.Object), + }) |object| { + _ = gobject.signalHandlersDisconnectMatched( + object, + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } self.alloc.destroy(self); } @@ -198,6 +238,65 @@ fn buildNav(self: *Browser) *gtk.Widget { return nav.as(gtk.Widget); } +fn buildFind(self: *Browser) *gtk.Widget { + const bar = self.find_bar.as(gtk.Widget); + bar.addCssClass("playpen-find"); + // Nothing to show until someone asks to search. + bar.setVisible(0); + + self.find_entry.setPlaceholderText("Find in page"); + self.find_entry.as(gtk.Widget).addCssClass("playpen-nav-entry"); + self.find_entry.as(gtk.Widget).addCssClass("playpen-find-entry"); + self.find_entry.as(gtk.Widget).setHexpand(1); + self.find_bar.append(self.find_entry.as(gtk.Widget)); + + // A search entry already debounces typing into `search-changed`, emits + // `activate` on Enter and `stop-search` on Escape, and carries its own + // clear button — all of which a plain entry would have needed building. + const signals = gtk.SearchEntry.signals; + _ = signals.search_changed.connect(self.find_entry, *Browser, &onFindChanged, self, .{}); + _ = signals.activate.connect(self.find_entry, *Browser, &onFindEntryNext, self, .{}); + _ = signals.next_match.connect(self.find_entry, *Browser, &onFindEntryNext, self, .{}); + _ = signals.previous_match.connect(self.find_entry, *Browser, &onFindEntryPrevious, self, .{}); + _ = signals.stop_search.connect(self.find_entry, *Browser, &onFindStop, self, .{}); + + // Shift+Enter for the previous match is the one binding the search entry + // doesn't come with. It has to run in the capture phase: the entry treats + // Enter as Enter whatever else is held down, so by the time the key + // bubbles back up it has already stepped forwards. + const keys = gtk.EventControllerKey.new(); + keys.as(gtk.EventController).setPropagationPhase(.capture); + _ = gtk.EventControllerKey.signals.key_pressed.connect( + keys, + *Browser, + &onFindKey, + self, + .{}, + ); + self.find_entry.as(gtk.Widget).addController(keys.as(gtk.EventController)); + + self.find_status.as(gtk.Widget).addCssClass("playpen-find-status"); + self.find_bar.append(self.find_status.as(gtk.Widget)); + + const previous = gtk.Button.newFromIconName("go-up-symbolic"); + const next = gtk.Button.newFromIconName("go-down-symbolic"); + const close = gtk.Button.newFromIconName("window-close-symbolic"); + for ([_]*gtk.Button{ previous, next, close }) |button| { + button.as(gtk.Widget).addCssClass("flat"); + button.as(gtk.Widget).addCssClass("playpen-nav-button"); + self.find_bar.append(button.as(gtk.Widget)); + } + previous.as(gtk.Widget).setTooltipText("Previous match"); + next.as(gtk.Widget).setTooltipText("Next match"); + close.as(gtk.Widget).setTooltipText("Close find bar"); + + _ = gtk.Button.signals.clicked.connect(previous, *Browser, &onFindPreviousClicked, self, .{}); + _ = gtk.Button.signals.clicked.connect(next, *Browser, &onFindNextClicked, self, .{}); + _ = gtk.Button.signals.clicked.connect(close, *Browser, &onFindCloseClicked, self, .{}); + + return bar; +} + /// Subscribe to one of the web view's properties. fn watch( self: *Browser, @@ -329,6 +428,83 @@ fn searchUrl(buf: []u8, query: []const u8) ?[:0]const u8 { return buf[0..w :0]; } +// ------------------------------------------------------------------------- +// Find in page +// +// WebKit does the searching and the highlighting; all that's here is the bar +// that drives it. Every operation is asynchronous, so what the bar reports +// comes from the controller's signals rather than from the calls above them. + +/// Open the find bar and put the cursor in it. Asking again with the bar +/// already open selects what's in it, so a second Ctrl+F starts a new search +/// rather than doing nothing. +pub fn openFind(self: *Browser) void { + self.find_bar.as(gtk.Widget).setVisible(1); + _ = self.find_entry.as(gtk.Widget).grabFocus(); + self.find_entry.as(gtk.Editable).selectRegion(0, -1); +} + +/// Close the find bar, ending the search and clearing its highlighting. +fn closeFind(self: *Browser) void { + self.finder().searchFinish(); + self.find_bar.as(gtk.Widget).setVisible(0); + self.find_status.setText(""); + self.find_entry.as(gtk.Widget).removeCssClass("error"); + + // Hand focus back to the page, so the keys that follow go where the + // cursor no longer is. + _ = self.view.as(gtk.Widget).grabFocus(); +} + +fn finder(self: *Browser) *webkit.FindController { + return self.view.getFindController(); +} + +fn findText(self: *Browser) [:0]const u8 { + return std.mem.span(self.find_entry.as(gtk.Editable).getText()); +} + +/// Start the search over from what's in the box, which is what every edit to +/// it does. +fn findSearch(self: *Browser) void { + const text = self.findText(); + if (text.len == 0) { + // An empty box isn't a failed search: drop the highlighting and say + // nothing rather than reporting no results. + self.finder().searchFinish(); + self.find_status.setText(""); + self.find_entry.as(gtk.Widget).removeCssClass("error"); + return; + } + // The count is asked for separately, and first. Stepping through matches + // re-reports whatever the step itself found, so a total taken from the + // search would collapse to "1 match" the moment you pressed Enter; this + // one is counted once and left alone until the text changes. + // + // Counting clears the marks a search leaves on the page, so it has to go + // ahead of the search rather than after it, or the highlighting the + // search just put down would be wiped by the count that followed. + self.finder().countMatches(text, find_options, find_max_matches); + self.finder().search(text, find_options, find_max_matches); +} + +fn findFailed(self: *Browser) void { + self.find_status.setText("No results"); + self.find_entry.as(gtk.Widget).addCssClass("error"); +} + +const Direction = enum { forward, backward }; + +fn findStep(self: *Browser, direction: Direction) void { + // Stepping reuses the running search, so there is nothing to step through + // until one has been started. + if (self.findText().len == 0) return; + switch (direction) { + .forward => self.finder().searchNext(), + .backward => self.finder().searchPrevious(), + } +} + // ------------------------------------------------------------------------- // Callbacks @@ -377,6 +553,83 @@ fn onClose(_: *webkit.WebView, self: *Browser) callconv(.c) void { self.on_exit(self.ctx); } +fn onFindChanged(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { + self.findSearch(); +} + +fn onFindEntryNext(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { + self.findStep(.forward); +} + +fn onFindEntryPrevious(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { + self.findStep(.backward); +} + +fn onFindStop(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { + self.closeFind(); +} + +fn onFindNextClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { + self.findStep(.forward); +} + +fn onFindPreviousClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { + self.findStep(.backward); +} + +fn onFindCloseClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { + self.closeFind(); +} + +fn onFindKey( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + state: gdk.ModifierType, + self: *Browser, +) callconv(.c) c_int { + if (!state.shift_mask) return 0; + switch (keyval) { + gdk.KEY_Return, gdk.KEY_KP_Enter, gdk.KEY_ISO_Enter => { + self.findStep(.backward); + return 1; + }, + else => return 0, + } +} + +fn onCountedMatches(_: *webkit.FindController, matches: c_uint, self: *Browser) callconv(.c) void { + if (matches == 0) { + // Counting and searching are separate operations, so this can land + // either side of `failed-to-find-text`. Both say the same thing. + self.findFailed(); + return; + } + self.find_entry.as(gtk.Widget).removeCssClass("error"); + + // WebKit stops counting at the cap it was given, so a page that reaches + // it gets a "or more" rather than a number that isn't the total. + var buf: [32]u8 = undefined; + const text = if (matches >= find_max_matches) + std.fmt.bufPrintZ(&buf, "{d}+ matches", .{find_max_matches}) catch return + else if (matches == 1) + std.fmt.bufPrintZ(&buf, "1 match", .{}) catch return + else + std.fmt.bufPrintZ(&buf, "{d} matches", .{matches}) catch return; + self.find_status.setText(text); +} + +/// A step landed on a match. The count stands as it was — this only takes +/// back a failure, which stepping past the end of a wrapping search can't +/// produce but a re-search after an edit can. +fn onFoundText(_: *webkit.FindController, _: c_uint, self: *Browser) callconv(.c) void { + self.find_entry.as(gtk.Widget).removeCssClass("error"); +} + +fn onFindFailed(_: *webkit.FindController, self: *Browser) callconv(.c) void { + self.findFailed(); +} + fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Browser) callconv(.c) void { self.on_focus(self.ctx); } diff --git a/src/Pane.zig b/src/Pane.zig index 4c452fd..2463fec 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -389,6 +389,16 @@ pub fn terminal(self: *Pane) ?*Terminal { }; } +/// The web view this pane holds, or null if it holds something else. The +/// mirror of `terminal`, for the operations only a page has — searching it, +/// say. +pub fn browser(self: *Pane) ?*Browser { + return switch (self.content) { + .web => |b| b, + .terminal => null, + }; +} + pub fn titleSlice(self: *const Pane) [:0]const u8 { return std.mem.sliceTo(&self.title, 0); } diff --git a/src/View.zig b/src/View.zig index ffca0e1..f4e109f 100644 --- a/src/View.zig +++ b/src/View.zig @@ -11,6 +11,7 @@ const std = @import("std"); const gtk = @import("gtk"); +const Browser = @import("Browser.zig"); const Layout = @import("Layout.zig"); const Layouts = @import("Layouts.zig"); const Pane = @import("Pane.zig"); @@ -225,6 +226,13 @@ pub fn focusedTerminal(self: *View) ?*Terminal { return pane.terminal(); } +/// The focused pane's web view, or null when the focused pane holds a +/// terminal instead. +pub fn focusedBrowser(self: *View) ?*Browser { + const pane = self.focusedPane() orelse return null; + return pane.browser(); +} + /// Icon for the tab row: whatever the focused pane is showing. pub fn iconName(self: *View) [:0]const u8 { const pane = self.focusedPane() orelse return Kind.terminal.iconName(); diff --git a/src/Window.zig b/src/Window.zig index d74a097..680fd91 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -14,6 +14,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const vt = @import("ghostty-vt"); +const Browser = @import("Browser.zig"); const Layouts = @import("Layouts.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); const Pane = @import("Pane.zig"); @@ -1348,6 +1349,12 @@ fn focusedTerminal(self: *Window) ?*Terminal { return tab.view.focusedTerminal(); } +/// The focused web view of the visible tab, or null when a terminal has focus. +fn focusedBrowser(self: *Window) ?*Browser { + const tab = self.activeTab() orelse return null; + return tab.view.focusedBrowser(); +} + /// Split the visible tab's focused pane, adding a pane of the given kind. fn addPane(self: *Window, kind: View.Kind) void { const tab = self.activeTab() orelse return; @@ -1442,10 +1449,21 @@ fn onShortcut( } } - // Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. - // Ctrl+comma opens settings, which is the convention nearly everywhere. + // Chords without Shift, which have to be the ones a terminal doesn't want + // for itself. Ctrl+PageUp/PageDown cycles tabs, matching most tabbed + // terminals; Ctrl+comma opens settings, which is the convention nearly + // everywhere; Ctrl+F is claimed only over a web pane. if (ctrl and !shift) { switch (keyval) { + gdk.KEY_F, gdk.KEY_f => { + // Find-in-page, on the chord every browser uses. In a + // terminal Ctrl+F is an ordinary control character that the + // program running there is waiting for, so this only claims + // the key when a web pane has focus. + const browser = self.focusedBrowser() orelse return 0; + browser.openFind(); + return 1; + }, gdk.KEY_comma => { self.openSettings(); return 1; diff --git a/src/style.css b/src/style.css index 76deae3..e571255 100644 --- a/src/style.css +++ b/src/style.css @@ -366,6 +366,26 @@ button.playpen-header-button:hover, border-color: @pp_accent; } +/* Find-in-page, in the same clothes as the nav bar above it: it is the same + kind of furniture, just one that comes and goes. */ +.playpen-find { + padding: 4px 6px; + background-color: @pp_surface; + border-bottom: 1px solid @pp_border; +} + +/* A search with no results says so in the box's border rather than only in + the count beside it, which is easy to miss mid-typing. */ +.playpen-find-entry.error { + border-color: @pp_err; +} + +.playpen-find-status { + margin: 0 4px; + font-size: 0.8em; + color: @pp_text_dim; +} + /* The pane currently filling its tab. The other panes are still open and still running, just not drawn, so this is marked quietly — a lit border rather than anything alarming. The header's toggle icon carries the rest of the message. */ diff --git a/src/webkit.zig b/src/webkit.zig index a2e77a0..a8cbc9f 100644 --- a/src/webkit.zig +++ b/src/webkit.zig @@ -67,6 +67,12 @@ pub const WebView = opaque { extern fn webkit_web_view_stop_loading(*WebView) void; pub const stopLoading = webkit_web_view_stop_loading; + /// The view's find controller, created on first use and owned by the view, + /// so this hands back the same object every time and it does not outlive + /// the view it came from. + extern fn webkit_web_view_get_find_controller(*WebView) *FindController; + pub const getFindController = webkit_web_view_get_find_controller; + extern fn webkit_web_view_is_loading(*WebView) c_int; pub fn isLoading(self: *WebView) bool { return truthy(webkit_web_view_is_loading(self)); @@ -99,3 +105,89 @@ pub const WebView = opaque { ); } }; + +/// Find-in-page for one web view. Obtained from `WebView.getFindController`, +/// which owns it. +/// +/// Every operation is asynchronous: `search` and its two step functions kick +/// off a search in the web process and the outcome arrives later on +/// `found-text` (carrying a match count) or `failed-to-find-text`. +pub const FindController = opaque { + /// WebKit's `WebKitFindOptions`. All-false is a forwards, case-sensitive + /// search that stops at the end of the document. + pub const Options = packed struct(u32) { + case_insensitive: bool = false, + at_word_starts: bool = false, + treat_medial_capital_as_word_start: bool = false, + backwards: bool = false, + wrap_around: bool = false, + _padding: u27 = 0, + }; + + pub fn as(self: *FindController, comptime T: type) *T { + comptime if (T != gobject.Object) @compileError( + "WebKitFindController can only be cast to gobject.Object", + ); + return @ptrCast(@alignCast(self)); + } + + /// Start a search, highlighting every match and selecting the first one + /// at or after the current position. `max_match_count` bounds the count + /// reported by `found-text`; matches past it are still highlighted. + extern fn webkit_find_controller_search(*FindController, [*:0]const u8, u32, c_uint) void; + pub fn search( + self: *FindController, + text: [*:0]const u8, + options: Options, + max_match_count: c_uint, + ) void { + webkit_find_controller_search(self, text, @bitCast(options), max_match_count); + } + + /// Count the matches for `text` without moving the selection. The total + /// arrives on `counted-matches`. + extern fn webkit_find_controller_count_matches(*FindController, [*:0]const u8, u32, c_uint) void; + pub fn countMatches( + self: *FindController, + text: [*:0]const u8, + options: Options, + max_match_count: c_uint, + ) void { + webkit_find_controller_count_matches(self, text, @bitCast(options), max_match_count); + } + + /// Step to the next or previous match of the search already running. + /// Both reuse the text and options the last `search` was given. + extern fn webkit_find_controller_search_next(*FindController) void; + pub const searchNext = webkit_find_controller_search_next; + + extern fn webkit_find_controller_search_previous(*FindController) void; + pub const searchPrevious = webkit_find_controller_search_previous; + + /// End the search, which is what clears the highlighting. + extern fn webkit_find_controller_search_finish(*FindController) void; + pub const searchFinish = webkit_find_controller_search_finish; + + /// Connect to a find controller signal. + /// + /// Unlike the web view's signals these differ in shape — `found-text` + /// carries a match count, `failed-to-find-text` carries nothing — so the + /// handler is taken as it comes and matching it to the signal is the + /// caller's job. + pub fn connectSignal( + self: *FindController, + comptime signal: [:0]const u8, + comptime Data: type, + handler: anytype, + data: Data, + ) void { + _ = gobject.signalConnectData( + self.as(gobject.Object), + signal, + @ptrCast(handler), + @ptrCast(data), + null, + .{}, + ); + } +};