diff --git a/src/Browser.zig b/src/Browser.zig index cb415cf..56a42bd 100644 --- a/src/Browser.zig +++ b/src/Browser.zig @@ -10,10 +10,10 @@ //! one through the same three signals. const std = @import("std"); -const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const FindBar = @import("FindBar.zig"); const Pane = @import("Pane.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. const url_max = 4096; -/// How find-in-page matches: the same case-insensitive, wrapping search every -/// browser's Ctrl+F does. -const find_options: webkit.FindController.Options = .{ - .case_insensitive = true, - .wrap_around = true, -}; - -/// How far WebKit is asked to count matches. A page with more than this many -/// is one where the exact number tells you nothing, and counting stops there -/// rather than tallying every occurrence in a huge document. -const find_max_matches = 1000; - alloc: std.mem.Allocator, /// Vertical box: nav bar on top, web view filling the rest. @@ -53,14 +41,10 @@ forward: *gtk.Button, /// Toggles between reload and stop depending on whether a load is running. reload: *gtk.Button, -/// Find-in-page bar, between the nav bar and the page. Hidden until Ctrl+F -/// asks for it, since a pane that isn't being searched shouldn't spend a row -/// of its height saying so. -find_bar: *gtk.Box, -find_entry: *gtk.SearchEntry, - -/// Match count, or the news that there weren't any. -find_status: *gtk.Label, +/// Find-in-page, between the nav bar and the page. Built by `create` rather +/// than in the initializer below, because its handlers are bound to its own +/// address — see `FindBar`. +find: FindBar, /// 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. @@ -93,9 +77,7 @@ pub fn create( .back = gtk.Button.newFromIconName("go-previous-symbolic"), .forward = gtk.Button.newFromIconName("go-next-symbolic"), .reload = gtk.Button.newFromIconName("view-refresh-symbolic"), - .find_bar = gtk.Box.new(.horizontal, 2), - .find_entry = gtk.SearchEntry.new(), - .find_status = gtk.Label.new(""), + .find = undefined, .on_title = cbs.on_title, .on_exit = cbs.on_exit, .on_focus = cbs.on_focus, @@ -103,7 +85,9 @@ pub fn create( }; 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); view_widget.setHexpand(1); @@ -122,13 +106,6 @@ pub fn create( // shell exiting closes a terminal's. self.view.connectSignal("close", *Browser, &onClose, self); - // Searching is asynchronous: every outcome arrives on one of these rather - // than from the call that started it. - const find = self.view.getFindController(); - find.connectSignal("counted-matches", *Browser, &onCountedMatches, self); - find.connectSignal("found-text", *Browser, &onFoundText, self); - find.connectSignal("failed-to-find-text", *Browser, &onFindFailed, self); - // Fires for focus landing anywhere inside, so clicking the address bar // marks the pane active just as clicking the page does. const focus = gtk.EventControllerFocus.new(); @@ -156,22 +133,19 @@ pub fn destroy(self: *Browser) void { // down a page makes WebKit emit property changes on the way out, so every // handler bound to `self` has to go before `self` does, or those changes // land on freed memory. - for ([_]*gobject.Object{ + _ = gobject.signalHandlersDisconnectMatched( 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 }, - 0, - 0, - null, - null, - self, - ); - } + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + + // The bar's own handlers sit on the find controller, which belongs to the + // view rather than to either of us, and carries the same hazard. + self.find.deinit(); self.alloc.destroy(self); } @@ -209,6 +183,11 @@ pub fn currentUrl(self: *Browser) []const u8 { 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. pub fn navigate(self: *Browser, input: []const u8) void { const uri = self.resolve(input) orelse return; @@ -238,65 +217,6 @@ fn buildNav(self: *Browser) *gtk.Widget { return nav.as(gtk.Widget); } -fn buildFind(self: *Browser) *gtk.Widget { - const bar = self.find_bar.as(gtk.Widget); - bar.addCssClass("playpen-find"); - // Nothing to show until someone asks to search. - bar.setVisible(0); - - self.find_entry.setPlaceholderText("Find in page"); - self.find_entry.as(gtk.Widget).addCssClass("playpen-nav-entry"); - self.find_entry.as(gtk.Widget).addCssClass("playpen-find-entry"); - self.find_entry.as(gtk.Widget).setHexpand(1); - self.find_bar.append(self.find_entry.as(gtk.Widget)); - - // A search entry already debounces typing into `search-changed`, emits - // `activate` on Enter and `stop-search` on Escape, and carries its own - // clear button — all of which a plain entry would have needed building. - const signals = gtk.SearchEntry.signals; - _ = signals.search_changed.connect(self.find_entry, *Browser, &onFindChanged, self, .{}); - _ = signals.activate.connect(self.find_entry, *Browser, &onFindEntryNext, self, .{}); - _ = signals.next_match.connect(self.find_entry, *Browser, &onFindEntryNext, self, .{}); - _ = signals.previous_match.connect(self.find_entry, *Browser, &onFindEntryPrevious, self, .{}); - _ = signals.stop_search.connect(self.find_entry, *Browser, &onFindStop, self, .{}); - - // Shift+Enter for the previous match is the one binding the search entry - // doesn't come with. It has to run in the capture phase: the entry treats - // Enter as Enter whatever else is held down, so by the time the key - // bubbles back up it has already stepped forwards. - const keys = gtk.EventControllerKey.new(); - keys.as(gtk.EventController).setPropagationPhase(.capture); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - keys, - *Browser, - &onFindKey, - self, - .{}, - ); - self.find_entry.as(gtk.Widget).addController(keys.as(gtk.EventController)); - - self.find_status.as(gtk.Widget).addCssClass("playpen-find-status"); - self.find_bar.append(self.find_status.as(gtk.Widget)); - - const previous = gtk.Button.newFromIconName("go-up-symbolic"); - const next = gtk.Button.newFromIconName("go-down-symbolic"); - const close = gtk.Button.newFromIconName("window-close-symbolic"); - for ([_]*gtk.Button{ previous, next, close }) |button| { - button.as(gtk.Widget).addCssClass("flat"); - button.as(gtk.Widget).addCssClass("playpen-nav-button"); - self.find_bar.append(button.as(gtk.Widget)); - } - previous.as(gtk.Widget).setTooltipText("Previous match"); - next.as(gtk.Widget).setTooltipText("Next match"); - close.as(gtk.Widget).setTooltipText("Close find bar"); - - _ = gtk.Button.signals.clicked.connect(previous, *Browser, &onFindPreviousClicked, self, .{}); - _ = gtk.Button.signals.clicked.connect(next, *Browser, &onFindNextClicked, self, .{}); - _ = gtk.Button.signals.clicked.connect(close, *Browser, &onFindCloseClicked, self, .{}); - - return bar; -} - /// Subscribe to one of the web view's properties. fn watch( self: *Browser, @@ -428,83 +348,6 @@ fn searchUrl(buf: []u8, query: []const u8) ?[:0]const u8 { return buf[0..w :0]; } -// ------------------------------------------------------------------------- -// Find in page -// -// WebKit does the searching and the highlighting; all that's here is the bar -// that drives it. Every operation is asynchronous, so what the bar reports -// comes from the controller's signals rather than from the calls above them. - -/// Open the find bar and put the cursor in it. Asking again with the bar -/// already open selects what's in it, so a second Ctrl+F starts a new search -/// rather than doing nothing. -pub fn openFind(self: *Browser) void { - self.find_bar.as(gtk.Widget).setVisible(1); - _ = self.find_entry.as(gtk.Widget).grabFocus(); - self.find_entry.as(gtk.Editable).selectRegion(0, -1); -} - -/// Close the find bar, ending the search and clearing its highlighting. -fn closeFind(self: *Browser) void { - self.finder().searchFinish(); - self.find_bar.as(gtk.Widget).setVisible(0); - self.find_status.setText(""); - self.find_entry.as(gtk.Widget).removeCssClass("error"); - - // Hand focus back to the page, so the keys that follow go where the - // cursor no longer is. - _ = self.view.as(gtk.Widget).grabFocus(); -} - -fn finder(self: *Browser) *webkit.FindController { - return self.view.getFindController(); -} - -fn findText(self: *Browser) [:0]const u8 { - return std.mem.span(self.find_entry.as(gtk.Editable).getText()); -} - -/// Start the search over from what's in the box, which is what every edit to -/// it does. -fn findSearch(self: *Browser) void { - const text = self.findText(); - if (text.len == 0) { - // An empty box isn't a failed search: drop the highlighting and say - // nothing rather than reporting no results. - self.finder().searchFinish(); - self.find_status.setText(""); - self.find_entry.as(gtk.Widget).removeCssClass("error"); - return; - } - // The count is asked for separately, and first. Stepping through matches - // re-reports whatever the step itself found, so a total taken from the - // search would collapse to "1 match" the moment you pressed Enter; this - // one is counted once and left alone until the text changes. - // - // Counting clears the marks a search leaves on the page, so it has to go - // ahead of the search rather than after it, or the highlighting the - // search just put down would be wiped by the count that followed. - self.finder().countMatches(text, find_options, find_max_matches); - self.finder().search(text, find_options, find_max_matches); -} - -fn findFailed(self: *Browser) void { - self.find_status.setText("No results"); - self.find_entry.as(gtk.Widget).addCssClass("error"); -} - -const Direction = enum { forward, backward }; - -fn findStep(self: *Browser, direction: Direction) void { - // Stepping reuses the running search, so there is nothing to step through - // until one has been started. - if (self.findText().len == 0) return; - switch (direction) { - .forward => self.finder().searchNext(), - .backward => self.finder().searchPrevious(), - } -} - // ------------------------------------------------------------------------- // Callbacks @@ -553,83 +396,6 @@ fn onClose(_: *webkit.WebView, self: *Browser) callconv(.c) void { self.on_exit(self.ctx); } -fn onFindChanged(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { - self.findSearch(); -} - -fn onFindEntryNext(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { - self.findStep(.forward); -} - -fn onFindEntryPrevious(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { - self.findStep(.backward); -} - -fn onFindStop(_: *gtk.SearchEntry, self: *Browser) callconv(.c) void { - self.closeFind(); -} - -fn onFindNextClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { - self.findStep(.forward); -} - -fn onFindPreviousClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { - self.findStep(.backward); -} - -fn onFindCloseClicked(_: *gtk.Button, self: *Browser) callconv(.c) void { - self.closeFind(); -} - -fn onFindKey( - _: *gtk.EventControllerKey, - keyval: c_uint, - _: c_uint, - state: gdk.ModifierType, - self: *Browser, -) callconv(.c) c_int { - if (!state.shift_mask) return 0; - switch (keyval) { - gdk.KEY_Return, gdk.KEY_KP_Enter, gdk.KEY_ISO_Enter => { - self.findStep(.backward); - return 1; - }, - else => return 0, - } -} - -fn onCountedMatches(_: *webkit.FindController, matches: c_uint, self: *Browser) callconv(.c) void { - if (matches == 0) { - // Counting and searching are separate operations, so this can land - // either side of `failed-to-find-text`. Both say the same thing. - self.findFailed(); - return; - } - self.find_entry.as(gtk.Widget).removeCssClass("error"); - - // WebKit stops counting at the cap it was given, so a page that reaches - // it gets a "or more" rather than a number that isn't the total. - var buf: [32]u8 = undefined; - const text = if (matches >= find_max_matches) - std.fmt.bufPrintZ(&buf, "{d}+ matches", .{find_max_matches}) catch return - else if (matches == 1) - std.fmt.bufPrintZ(&buf, "1 match", .{}) catch return - else - std.fmt.bufPrintZ(&buf, "{d} matches", .{matches}) catch return; - self.find_status.setText(text); -} - -/// A step landed on a match. The count stands as it was — this only takes -/// back a failure, which stepping past the end of a wrapping search can't -/// produce but a re-search after an edit can. -fn onFoundText(_: *webkit.FindController, _: c_uint, self: *Browser) callconv(.c) void { - self.find_entry.as(gtk.Widget).removeCssClass("error"); -} - -fn onFindFailed(_: *webkit.FindController, self: *Browser) callconv(.c) void { - self.findFailed(); -} - fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Browser) callconv(.c) void { self.on_focus(self.ctx); } diff --git a/src/FindBar.zig b/src/FindBar.zig new file mode 100644 index 0000000..9625027 --- /dev/null +++ b/src/FindBar.zig @@ -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(); +} diff --git a/src/Review.zig b/src/Review.zig index f6d48ff..e40b33f 100644 --- a/src/Review.zig +++ b/src/Review.zig @@ -25,6 +25,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const FindBar = @import("FindBar.zig"); const Pane = @import("Pane.zig"); const webkit = @import("webkit.zig"); @@ -53,7 +54,8 @@ const retry_delay_ms = 400; 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, view: *webkit.WebView, @@ -62,6 +64,13 @@ view: *webkit.WebView, error_bar: *gtk.Box, 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. url: [:0]u8, @@ -98,6 +107,7 @@ pub fn create( .view = .new(), .error_bar = gtk.Box.new(.horizontal, 8), .error_label = gtk.Label.new(""), + .find = undefined, .url = url, .on_title = cbs.on_title, .on_exit = cbs.on_exit, @@ -108,6 +118,9 @@ pub fn create( self.box.append(self.buildErrorBar()); + self.find.init(self.view); + self.box.append(self.find.widget()); + const view_widget = self.view.as(gtk.Widget); view_widget.setHexpand(1); view_widget.setVexpand(1); @@ -166,6 +179,10 @@ pub fn destroy(self: *Review) void { 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.destroy(self); } @@ -183,6 +200,16 @@ pub fn title(self: *const Review) []const u8 { 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 /// button, so a server that came up late can be picked up without reopening. pub fn reload(self: *Review) void { diff --git a/src/View.zig b/src/View.zig index 56597a0..b3b4c5f 100644 --- a/src/View.zig +++ b/src/View.zig @@ -255,6 +255,15 @@ pub fn focusedBrowser(self: *View) ?*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. pub fn iconName(self: *View) [:0]const u8 { const pane = self.focusedPane() orelse return Kind.terminal.iconName(); diff --git a/src/Window.zig b/src/Window.zig index d063c5c..41a4330 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -18,6 +18,7 @@ const Browser = @import("Browser.zig"); const Layouts = @import("Layouts.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); const Pane = @import("Pane.zig"); +const Review = @import("Review.zig"); const SaveLayoutDialog = @import("SaveLayoutDialog.zig"); const Settings = @import("Settings.zig"); const SettingsDialog = @import("SettingsDialog.zig"); @@ -1860,6 +1861,11 @@ fn focusedBrowser(self: *Window) ?*Browser { 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. fn addPane(self: *Window, kind: View.Kind) void { 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 // 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 => { - const browser = self.focusedBrowser() orelse return false; - browser.openFind(); + if (self.focusedBrowser()) |browser| { + browser.openFind(); + } else if (self.focusedReview()) |pane| { + pane.openFind(); + } else return false; }, .prev_tab => self.cycle(-1),