Add ctrl+f for review tool.
This commit is contained in:
+26
-260
@@ -10,10 +10,10 @@
|
|||||||
//! 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");
|
||||||
|
|
||||||
|
const FindBar = @import("FindBar.zig");
|
||||||
const Pane = @import("Pane.zig");
|
const Pane = @import("Pane.zig");
|
||||||
const webkit = @import("webkit.zig");
|
const webkit = @import("webkit.zig");
|
||||||
|
|
||||||
@@ -27,18 +27,6 @@ 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.
|
||||||
@@ -53,14 +41,10 @@ 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
|
/// Find-in-page, between the nav bar and the page. Built by `create` rather
|
||||||
/// asks for it, since a pane that isn't being searched shouldn't spend a row
|
/// than in the initializer below, because its handlers are bound to its own
|
||||||
/// of its height saying so.
|
/// address — see `FindBar`.
|
||||||
find_bar: *gtk.Box,
|
find: FindBar,
|
||||||
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.
|
||||||
@@ -93,9 +77,7 @@ 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 = undefined,
|
||||||
.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,
|
||||||
@@ -103,7 +85,9 @@ pub fn create(
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.box.append(self.buildNav());
|
self.box.append(self.buildNav());
|
||||||
self.box.append(self.buildFind());
|
|
||||||
|
self.find.init(self.view);
|
||||||
|
self.box.append(self.find.widget());
|
||||||
|
|
||||||
const view_widget = self.view.as(gtk.Widget);
|
const view_widget = self.view.as(gtk.Widget);
|
||||||
view_widget.setHexpand(1);
|
view_widget.setHexpand(1);
|
||||||
@@ -122,13 +106,6 @@ 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();
|
||||||
@@ -156,22 +133,19 @@ 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.
|
||||||
for ([_]*gobject.Object{
|
_ = gobject.signalHandlersDisconnectMatched(
|
||||||
self.view.as(gobject.Object),
|
self.view.as(gobject.Object),
|
||||||
// The find controller is a second object with the same lifetime and
|
.{ .data = true },
|
||||||
// the same hazard: it belongs to the view, not to us.
|
0,
|
||||||
self.view.getFindController().as(gobject.Object),
|
0,
|
||||||
}) |object| {
|
null,
|
||||||
_ = gobject.signalHandlersDisconnectMatched(
|
null,
|
||||||
object,
|
self,
|
||||||
.{ .data = true },
|
);
|
||||||
0,
|
|
||||||
0,
|
// The bar's own handlers sit on the find controller, which belongs to the
|
||||||
null,
|
// view rather than to either of us, and carries the same hazard.
|
||||||
null,
|
self.find.deinit();
|
||||||
self,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.alloc.destroy(self);
|
self.alloc.destroy(self);
|
||||||
}
|
}
|
||||||
@@ -209,6 +183,11 @@ pub fn currentUrl(self: *Browser) []const u8 {
|
|||||||
return std.mem.span(uri);
|
return std.mem.span(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the find bar. The window's Ctrl+F lands here.
|
||||||
|
pub fn openFind(self: *Browser) void {
|
||||||
|
self.find.open();
|
||||||
|
}
|
||||||
|
|
||||||
/// Load an address, applying the same interpretation the address bar does.
|
/// Load an address, applying the same interpretation the address bar does.
|
||||||
pub fn navigate(self: *Browser, input: []const u8) void {
|
pub fn navigate(self: *Browser, input: []const u8) void {
|
||||||
const uri = self.resolve(input) orelse return;
|
const uri = self.resolve(input) orelse return;
|
||||||
@@ -238,65 +217,6 @@ 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,
|
||||||
@@ -428,83 +348,6 @@ 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
|
||||||
|
|
||||||
@@ -553,83 +396,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
//! Find-in-page for a web view: the bar, and the search it drives.
|
||||||
|
//!
|
||||||
|
//! This started life inside `Browser`, which was the only pane with a page to
|
||||||
|
//! search. The review pane is the second one, and it wants exactly the same
|
||||||
|
//! thing — the same chord, the same bar, the same wrapping case-insensitive
|
||||||
|
//! match — so the bar moved here rather than being written twice with two sets
|
||||||
|
//! of off-by-one bugs in the match counting.
|
||||||
|
//!
|
||||||
|
//! 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 find controller's signals rather than from the calls that
|
||||||
|
//! started them.
|
||||||
|
//!
|
||||||
|
//! **Embed it by value and call `init` in place.** The signal handlers are
|
||||||
|
//! bound to `&self`, so a `FindBar` that gets copied after `init` leaves them
|
||||||
|
//! pointing at the original. Both current owners are heap-allocated structs
|
||||||
|
//! that hold one as a field, which is the shape this expects.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const gdk = @import("gdk");
|
||||||
|
const gobject = @import("gobject");
|
||||||
|
const gtk = @import("gtk");
|
||||||
|
|
||||||
|
const webkit = @import("webkit.zig");
|
||||||
|
|
||||||
|
const FindBar = @This();
|
||||||
|
|
||||||
|
/// How find-in-page matches: the same case-insensitive, wrapping search every
|
||||||
|
/// browser's Ctrl+F does.
|
||||||
|
const 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 max_matches = 1000;
|
||||||
|
|
||||||
|
/// The view being searched. Not owned — the bar is a control for someone
|
||||||
|
/// else's page.
|
||||||
|
view: *webkit.WebView,
|
||||||
|
|
||||||
|
/// The bar itself, hidden until Ctrl+F asks for it: a pane that isn't being
|
||||||
|
/// searched shouldn't spend a row of its height saying so.
|
||||||
|
bar: *gtk.Box,
|
||||||
|
|
||||||
|
entry: *gtk.SearchEntry,
|
||||||
|
|
||||||
|
/// Match count, or the news that there weren't any.
|
||||||
|
status: *gtk.Label,
|
||||||
|
|
||||||
|
/// Build the bar for `web_view`, in place.
|
||||||
|
///
|
||||||
|
/// Nothing is appended to a parent here; the owner decides where the bar sits
|
||||||
|
/// in its own box, which is above the page in both of them.
|
||||||
|
pub fn init(self: *FindBar, web_view: *webkit.WebView) void {
|
||||||
|
self.* = .{
|
||||||
|
.view = web_view,
|
||||||
|
.bar = gtk.Box.new(.horizontal, 2),
|
||||||
|
.entry = gtk.SearchEntry.new(),
|
||||||
|
.status = gtk.Label.new(""),
|
||||||
|
};
|
||||||
|
|
||||||
|
const bar = self.bar.as(gtk.Widget);
|
||||||
|
bar.addCssClass("playpen-find");
|
||||||
|
// Nothing to show until someone asks to search.
|
||||||
|
bar.setVisible(0);
|
||||||
|
|
||||||
|
self.entry.setPlaceholderText("Find in page");
|
||||||
|
self.entry.as(gtk.Widget).addCssClass("playpen-nav-entry");
|
||||||
|
self.entry.as(gtk.Widget).addCssClass("playpen-find-entry");
|
||||||
|
self.entry.as(gtk.Widget).setHexpand(1);
|
||||||
|
self.bar.append(self.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.entry, *FindBar, &onChanged, self, .{});
|
||||||
|
_ = signals.activate.connect(self.entry, *FindBar, &onEntryNext, self, .{});
|
||||||
|
_ = signals.next_match.connect(self.entry, *FindBar, &onEntryNext, self, .{});
|
||||||
|
_ = signals.previous_match.connect(self.entry, *FindBar, &onEntryPrevious, self, .{});
|
||||||
|
_ = signals.stop_search.connect(self.entry, *FindBar, &onStop, 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,
|
||||||
|
*FindBar,
|
||||||
|
&onKey,
|
||||||
|
self,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
self.entry.as(gtk.Widget).addController(keys.as(gtk.EventController));
|
||||||
|
|
||||||
|
self.status.as(gtk.Widget).addCssClass("playpen-find-status");
|
||||||
|
self.bar.append(self.status.as(gtk.Widget));
|
||||||
|
|
||||||
|
const previous = gtk.Button.newFromIconName("go-up-symbolic");
|
||||||
|
const next = gtk.Button.newFromIconName("go-down-symbolic");
|
||||||
|
const dismiss = gtk.Button.newFromIconName("window-close-symbolic");
|
||||||
|
for ([_]*gtk.Button{ previous, next, dismiss }) |button| {
|
||||||
|
button.as(gtk.Widget).addCssClass("flat");
|
||||||
|
button.as(gtk.Widget).addCssClass("playpen-nav-button");
|
||||||
|
self.bar.append(button.as(gtk.Widget));
|
||||||
|
}
|
||||||
|
previous.as(gtk.Widget).setTooltipText("Previous match");
|
||||||
|
next.as(gtk.Widget).setTooltipText("Next match");
|
||||||
|
dismiss.as(gtk.Widget).setTooltipText("Close find bar");
|
||||||
|
|
||||||
|
_ = gtk.Button.signals.clicked.connect(previous, *FindBar, &onPreviousClicked, self, .{});
|
||||||
|
_ = gtk.Button.signals.clicked.connect(next, *FindBar, &onNextClicked, self, .{});
|
||||||
|
_ = gtk.Button.signals.clicked.connect(dismiss, *FindBar, &onCloseClicked, 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", *FindBar, &onCountedMatches, self);
|
||||||
|
find.connectSignal("found-text", *FindBar, &onFoundText, self);
|
||||||
|
find.connectSignal("failed-to-find-text", *FindBar, &onFailed, self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the handlers bound to this bar, before the memory holding it goes.
|
||||||
|
///
|
||||||
|
/// The find controller belongs to the view rather than to us, and tearing a
|
||||||
|
/// page down makes WebKit emit on the way out, so a handler still pointing here
|
||||||
|
/// would land on freed memory. The widget handlers go with the widgets; this is
|
||||||
|
/// only about the controller.
|
||||||
|
pub fn deinit(self: *FindBar) void {
|
||||||
|
_ = gobject.signalHandlersDisconnectMatched(
|
||||||
|
self.view.getFindController().as(gobject.Object),
|
||||||
|
.{ .data = true },
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
self,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar, for the owner to place in its own box.
|
||||||
|
pub fn widget(self: *FindBar) *gtk.Widget {
|
||||||
|
return self.bar.as(gtk.Widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the 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 open(self: *FindBar) void {
|
||||||
|
self.bar.as(gtk.Widget).setVisible(1);
|
||||||
|
_ = self.entry.as(gtk.Widget).grabFocus();
|
||||||
|
self.entry.as(gtk.Editable).selectRegion(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the bar, ending the search and clearing its highlighting.
|
||||||
|
fn close(self: *FindBar) void {
|
||||||
|
self.finder().searchFinish();
|
||||||
|
self.bar.as(gtk.Widget).setVisible(0);
|
||||||
|
self.status.setText("");
|
||||||
|
self.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: *FindBar) *webkit.FindController {
|
||||||
|
return self.view.getFindController();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(self: *FindBar) [:0]const u8 {
|
||||||
|
return std.mem.span(self.entry.as(gtk.Editable).getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the search over from what's in the box, which is what every edit to
|
||||||
|
/// it does.
|
||||||
|
fn search(self: *FindBar) void {
|
||||||
|
const needle = self.text();
|
||||||
|
if (needle.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.status.setText("");
|
||||||
|
self.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(needle, options, max_matches);
|
||||||
|
self.finder().search(needle, options, max_matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(self: *FindBar) void {
|
||||||
|
self.status.setText("No results");
|
||||||
|
self.entry.as(gtk.Widget).addCssClass("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const Direction = enum { forward, backward };
|
||||||
|
|
||||||
|
fn step(self: *FindBar, direction: Direction) void {
|
||||||
|
// Stepping reuses the running search, so there is nothing to step through
|
||||||
|
// until one has been started.
|
||||||
|
if (self.text().len == 0) return;
|
||||||
|
switch (direction) {
|
||||||
|
.forward => self.finder().searchNext(),
|
||||||
|
.backward => self.finder().searchPrevious(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Callbacks
|
||||||
|
|
||||||
|
fn onChanged(_: *gtk.SearchEntry, self: *FindBar) callconv(.c) void {
|
||||||
|
self.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onEntryNext(_: *gtk.SearchEntry, self: *FindBar) callconv(.c) void {
|
||||||
|
self.step(.forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onEntryPrevious(_: *gtk.SearchEntry, self: *FindBar) callconv(.c) void {
|
||||||
|
self.step(.backward);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onStop(_: *gtk.SearchEntry, self: *FindBar) callconv(.c) void {
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onNextClicked(_: *gtk.Button, self: *FindBar) callconv(.c) void {
|
||||||
|
self.step(.forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onPreviousClicked(_: *gtk.Button, self: *FindBar) callconv(.c) void {
|
||||||
|
self.step(.backward);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onCloseClicked(_: *gtk.Button, self: *FindBar) callconv(.c) void {
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onKey(
|
||||||
|
_: *gtk.EventControllerKey,
|
||||||
|
keyval: c_uint,
|
||||||
|
_: c_uint,
|
||||||
|
state: gdk.ModifierType,
|
||||||
|
self: *FindBar,
|
||||||
|
) callconv(.c) c_int {
|
||||||
|
if (!state.shift_mask) return 0;
|
||||||
|
switch (keyval) {
|
||||||
|
gdk.KEY_Return, gdk.KEY_KP_Enter, gdk.KEY_ISO_Enter => {
|
||||||
|
self.step(.backward);
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
else => return 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onCountedMatches(_: *webkit.FindController, matches: c_uint, self: *FindBar) 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.failed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.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 label = if (matches >= max_matches)
|
||||||
|
std.fmt.bufPrintZ(&buf, "{d}+ matches", .{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.status.setText(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: *FindBar) callconv(.c) void {
|
||||||
|
self.entry.as(gtk.Widget).removeCssClass("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onFailed(_: *webkit.FindController, self: *FindBar) callconv(.c) void {
|
||||||
|
self.failed();
|
||||||
|
}
|
||||||
+28
-1
@@ -25,6 +25,7 @@ const glib = @import("glib");
|
|||||||
const gobject = @import("gobject");
|
const gobject = @import("gobject");
|
||||||
const gtk = @import("gtk");
|
const gtk = @import("gtk");
|
||||||
|
|
||||||
|
const FindBar = @import("FindBar.zig");
|
||||||
const Pane = @import("Pane.zig");
|
const Pane = @import("Pane.zig");
|
||||||
const webkit = @import("webkit.zig");
|
const webkit = @import("webkit.zig");
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ const retry_delay_ms = 400;
|
|||||||
|
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
|
|
||||||
/// Vertical box: the error bar (hidden in the normal case) above the page.
|
/// Vertical box: the error bar and the find bar — both hidden in the normal
|
||||||
|
/// case — above the page.
|
||||||
box: *gtk.Box,
|
box: *gtk.Box,
|
||||||
|
|
||||||
view: *webkit.WebView,
|
view: *webkit.WebView,
|
||||||
@@ -62,6 +64,13 @@ view: *webkit.WebView,
|
|||||||
error_bar: *gtk.Box,
|
error_bar: *gtk.Box,
|
||||||
error_label: *gtk.Label,
|
error_label: *gtk.Label,
|
||||||
|
|
||||||
|
/// Find-in-page, the same bar a web pane gets. A review is a long page — a
|
||||||
|
/// branch's worth of diff, with a rail of comments beside it — and looking for
|
||||||
|
/// a symbol in it is exactly the thing Ctrl+F is for. Built by `create` rather
|
||||||
|
/// than in the initializer below, because its handlers are bound to its own
|
||||||
|
/// address — see `FindBar`.
|
||||||
|
find: FindBar,
|
||||||
|
|
||||||
/// The endpoint this pane is bound to, NUL-terminated for WebKit. Owned.
|
/// The endpoint this pane is bound to, NUL-terminated for WebKit. Owned.
|
||||||
url: [:0]u8,
|
url: [:0]u8,
|
||||||
|
|
||||||
@@ -98,6 +107,7 @@ pub fn create(
|
|||||||
.view = .new(),
|
.view = .new(),
|
||||||
.error_bar = gtk.Box.new(.horizontal, 8),
|
.error_bar = gtk.Box.new(.horizontal, 8),
|
||||||
.error_label = gtk.Label.new(""),
|
.error_label = gtk.Label.new(""),
|
||||||
|
.find = undefined,
|
||||||
.url = url,
|
.url = url,
|
||||||
.on_title = cbs.on_title,
|
.on_title = cbs.on_title,
|
||||||
.on_exit = cbs.on_exit,
|
.on_exit = cbs.on_exit,
|
||||||
@@ -108,6 +118,9 @@ pub fn create(
|
|||||||
|
|
||||||
self.box.append(self.buildErrorBar());
|
self.box.append(self.buildErrorBar());
|
||||||
|
|
||||||
|
self.find.init(self.view);
|
||||||
|
self.box.append(self.find.widget());
|
||||||
|
|
||||||
const view_widget = self.view.as(gtk.Widget);
|
const view_widget = self.view.as(gtk.Widget);
|
||||||
view_widget.setHexpand(1);
|
view_widget.setHexpand(1);
|
||||||
view_widget.setVexpand(1);
|
view_widget.setVexpand(1);
|
||||||
@@ -166,6 +179,10 @@ pub fn destroy(self: *Review) void {
|
|||||||
self,
|
self,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The bar's handlers sit on the find controller, which is a second object
|
||||||
|
// with the same lifetime and the same hazard.
|
||||||
|
self.find.deinit();
|
||||||
|
|
||||||
self.alloc.free(self.url);
|
self.alloc.free(self.url);
|
||||||
self.alloc.destroy(self);
|
self.alloc.destroy(self);
|
||||||
}
|
}
|
||||||
@@ -183,6 +200,16 @@ pub fn title(self: *const Review) []const u8 {
|
|||||||
return std.mem.sliceTo(&self.label, 0);
|
return std.mem.sliceTo(&self.label, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the find bar. The window's Ctrl+F lands here.
|
||||||
|
///
|
||||||
|
/// What it can reach is what the page has rendered, which is the same deal a
|
||||||
|
/// browser's Ctrl+F offers: a file collapsed because it is marked viewed, and
|
||||||
|
/// context still folded behind an expander, are not in the document and so are
|
||||||
|
/// not searched.
|
||||||
|
pub fn openFind(self: *Review) void {
|
||||||
|
self.find.open();
|
||||||
|
}
|
||||||
|
|
||||||
/// Reload the page. Bound to the pane's own reload, and to the error bar's
|
/// Reload the page. Bound to the pane's own reload, and to the error bar's
|
||||||
/// button, so a server that came up late can be picked up without reopening.
|
/// button, so a server that came up late can be picked up without reopening.
|
||||||
pub fn reload(self: *Review) void {
|
pub fn reload(self: *Review) void {
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ pub fn focusedBrowser(self: *View) ?*Browser {
|
|||||||
return pane.browser();
|
return pane.browser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The focused pane's review, or null when the focused pane holds something
|
||||||
|
/// else. Note the difference from `reviewPane`, which finds the tab's review
|
||||||
|
/// wherever it is: this one only answers when it is the pane being typed at,
|
||||||
|
/// which is what an operation on "the page in front of you" needs.
|
||||||
|
pub fn focusedReview(self: *View) ?*Review {
|
||||||
|
const pane = self.focusedPane() orelse return null;
|
||||||
|
return pane.review();
|
||||||
|
}
|
||||||
|
|
||||||
/// 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();
|
||||||
|
|||||||
+13
-3
@@ -18,6 +18,7 @@ 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");
|
||||||
|
const Review = @import("Review.zig");
|
||||||
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
||||||
const Settings = @import("Settings.zig");
|
const Settings = @import("Settings.zig");
|
||||||
const SettingsDialog = @import("SettingsDialog.zig");
|
const SettingsDialog = @import("SettingsDialog.zig");
|
||||||
@@ -1860,6 +1861,11 @@ fn focusedBrowser(self: *Window) ?*Browser {
|
|||||||
return tab.view.focusedBrowser();
|
return tab.view.focusedBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn focusedReview(self: *Window) ?*Review {
|
||||||
|
const tab = self.activeTab() orelse return null;
|
||||||
|
return tab.view.focusedReview();
|
||||||
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -2126,10 +2132,14 @@ fn perform(self: *Window, action: shortcuts.Action) bool {
|
|||||||
|
|
||||||
// Find-in-page, on the chord every browser uses. In a terminal Ctrl+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
|
// is an ordinary control character that the program running there is
|
||||||
// waiting for, so this only claims the key over a web pane.
|
// waiting for, so this only claims the key over a pane holding a page.
|
||||||
|
// Both kinds that do put up the same bar.
|
||||||
.find => {
|
.find => {
|
||||||
const browser = self.focusedBrowser() orelse return false;
|
if (self.focusedBrowser()) |browser| {
|
||||||
browser.openFind();
|
browser.openFind();
|
||||||
|
} else if (self.focusedReview()) |pane| {
|
||||||
|
pane.openFind();
|
||||||
|
} else return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
.prev_tab => self.cycle(-1),
|
.prev_tab => self.cycle(-1),
|
||||||
|
|||||||
Reference in New Issue
Block a user