Add find shortcut in browser.

This commit is contained in:
Greyson Parrelli
2026-08-13 11:18:33 -04:00
parent 4e622b15af
commit 71b3c93521
7 changed files with 437 additions and 14 deletions
+26 -4
View File
@@ -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 Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
Pane.zig content plus its header, drag source, and drop target Pane.zig content plus its header, drag source, and drop target
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling 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 webkit.zig hand-written bindings for the WebKitGTK calls we make
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}} Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
OpenLayoutDialog.zig prompts for a layout's parameters 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 - **Web panes**, on WebKitGTK, sitting in the split tree beside terminals and
dragging around exactly like they do: back/forward/reload, an address bar 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 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 - **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
and scripts — opened in one go, parameterised by `{{name}}`, authored by and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts) 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+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+R` | rename the current tab (empty name = follow the terminal) |
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back | | `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+,` | settings |
| `Ctrl+PageUp/PageDown` | previous / next tab | | `Ctrl+PageUp/PageDown` | previous / next tab |
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | | `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 closing — a background shell exiting, say — does not, since what you are looking
at is still there. 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 ## Tab settings
Right-clicking a tab row opens a small menu: **Rename**, which is the same 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. look right.
- **Moving panes between tabs.** Panes can only be rearranged within their - **Moving panes between tabs.** Panes can only be rearranged within their
own view. own view.
- **Browser furniture.** Web panes get navigation and an address bar and - **Browser furniture.** Web panes get navigation, an address bar and
nothing else: no bookmarks, history, downloads, devtools, or find-in-page, 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 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. runs. Links that ask for a new window are ignored rather than opening a pane.
- **Restoring a session.** [Startup tabs](#startup-tabs) reopen the - **Restoring a session.** [Startup tabs](#startup-tabs) reopen the
+254 -1
View File
@@ -10,6 +10,7 @@
//! one through the same three signals. //! one through the same three signals.
const std = @import("std"); const std = @import("std");
const gdk = @import("gdk");
const gobject = @import("gobject"); const gobject = @import("gobject");
const gtk = @import("gtk"); const gtk = @import("gtk");
@@ -26,6 +27,18 @@ const search_prefix = "https://duckduckgo.com/?q=";
/// search query that triples in length under percent-encoding. /// search query that triples in length under percent-encoding.
const url_max = 4096; 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, alloc: std.mem.Allocator,
/// Vertical box: nav bar on top, web view filling the rest. /// 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. /// Toggles between reload and stop depending on whether a load is running.
reload: *gtk.Button, 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 /// 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. /// stack because it is larger than a callback frame wants to carry.
url_buf: [url_max]u8 = undefined, url_buf: [url_max]u8 = undefined,
@@ -71,6 +93,9 @@ pub fn create(
.back = gtk.Button.newFromIconName("go-previous-symbolic"), .back = gtk.Button.newFromIconName("go-previous-symbolic"),
.forward = gtk.Button.newFromIconName("go-next-symbolic"), .forward = gtk.Button.newFromIconName("go-next-symbolic"),
.reload = gtk.Button.newFromIconName("view-refresh-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_title = cbs.on_title,
.on_exit = cbs.on_exit, .on_exit = cbs.on_exit,
.on_focus = cbs.on_focus, .on_focus = cbs.on_focus,
@@ -78,6 +103,7 @@ pub fn create(
}; };
self.box.append(self.buildNav()); self.box.append(self.buildNav());
self.box.append(self.buildFind());
const view_widget = self.view.as(gtk.Widget); const view_widget = self.view.as(gtk.Widget);
view_widget.setHexpand(1); view_widget.setHexpand(1);
@@ -96,6 +122,13 @@ pub fn create(
// shell exiting closes a terminal's. // shell exiting closes a terminal's.
self.view.connectSignal("close", *Browser, &onClose, self); 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 // Fires for focus landing anywhere inside, so clicking the address bar
// marks the pane active just as clicking the page does. // marks the pane active just as clicking the page does.
const focus = gtk.EventControllerFocus.new(); const focus = gtk.EventControllerFocus.new();
@@ -123,8 +156,14 @@ pub fn destroy(self: *Browser) void {
// down a page makes WebKit emit property changes on the way out, so every // 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 // handler bound to `self` has to go before `self` does, or those changes
// land on freed memory. // land on freed memory.
_ = gobject.signalHandlersDisconnectMatched( for ([_]*gobject.Object{
self.view.as(gobject.Object), self.view.as(gobject.Object),
// 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 }, .{ .data = true },
0, 0,
0, 0,
@@ -132,6 +171,7 @@ pub fn destroy(self: *Browser) void {
null, null,
self, self,
); );
}
self.alloc.destroy(self); self.alloc.destroy(self);
} }
@@ -198,6 +238,65 @@ fn buildNav(self: *Browser) *gtk.Widget {
return nav.as(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. /// Subscribe to one of the web view's properties.
fn watch( fn watch(
self: *Browser, self: *Browser,
@@ -329,6 +428,83 @@ fn searchUrl(buf: []u8, query: []const u8) ?[:0]const u8 {
return buf[0..w :0]; 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 // Callbacks
@@ -377,6 +553,83 @@ fn onClose(_: *webkit.WebView, self: *Browser) callconv(.c) void {
self.on_exit(self.ctx); 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 { fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Browser) callconv(.c) void {
self.on_focus(self.ctx); self.on_focus(self.ctx);
} }
+10
View File
@@ -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 { pub fn titleSlice(self: *const Pane) [:0]const u8 {
return std.mem.sliceTo(&self.title, 0); return std.mem.sliceTo(&self.title, 0);
} }
+8
View File
@@ -11,6 +11,7 @@
const std = @import("std"); const std = @import("std");
const gtk = @import("gtk"); const gtk = @import("gtk");
const Browser = @import("Browser.zig");
const Layout = @import("Layout.zig"); const Layout = @import("Layout.zig");
const Layouts = @import("Layouts.zig"); const Layouts = @import("Layouts.zig");
const Pane = @import("Pane.zig"); const Pane = @import("Pane.zig");
@@ -225,6 +226,13 @@ pub fn focusedTerminal(self: *View) ?*Terminal {
return pane.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. /// Icon for the tab row: whatever the focused pane is showing.
pub fn iconName(self: *View) [:0]const u8 { pub fn iconName(self: *View) [:0]const u8 {
const pane = self.focusedPane() orelse return Kind.terminal.iconName(); const pane = self.focusedPane() orelse return Kind.terminal.iconName();
+20 -2
View File
@@ -14,6 +14,7 @@ const gobject = @import("gobject");
const gtk = @import("gtk"); const gtk = @import("gtk");
const vt = @import("ghostty-vt"); const vt = @import("ghostty-vt");
const Browser = @import("Browser.zig");
const Layouts = @import("Layouts.zig"); const Layouts = @import("Layouts.zig");
const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
const Pane = @import("Pane.zig"); const Pane = @import("Pane.zig");
@@ -1348,6 +1349,12 @@ fn focusedTerminal(self: *Window) ?*Terminal {
return tab.view.focusedTerminal(); 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. /// Split the visible tab's focused pane, adding a pane of the given kind.
fn addPane(self: *Window, kind: View.Kind) void { fn addPane(self: *Window, kind: View.Kind) void {
const tab = self.activeTab() orelse return; const tab = self.activeTab() orelse return;
@@ -1442,10 +1449,21 @@ fn onShortcut(
} }
} }
// Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. // Chords without Shift, which have to be the ones a terminal doesn't want
// Ctrl+comma opens settings, which is the convention nearly everywhere. // 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) { if (ctrl and !shift) {
switch (keyval) { 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 => { gdk.KEY_comma => {
self.openSettings(); self.openSettings();
return 1; return 1;
+20
View File
@@ -366,6 +366,26 @@ button.playpen-header-button:hover,
border-color: @pp_accent; 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 /* 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 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. */ anything alarming. The header's toggle icon carries the rest of the message. */
+92
View File
@@ -67,6 +67,12 @@ pub const WebView = opaque {
extern fn webkit_web_view_stop_loading(*WebView) void; extern fn webkit_web_view_stop_loading(*WebView) void;
pub const stopLoading = webkit_web_view_stop_loading; 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; extern fn webkit_web_view_is_loading(*WebView) c_int;
pub fn isLoading(self: *WebView) bool { pub fn isLoading(self: *WebView) bool {
return truthy(webkit_web_view_is_loading(self)); 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,
.{},
);
}
};