Add find shortcut in browser.
This commit is contained in:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user