Find in page: the shell's find bar per pane (Ctrl+F, the app menu, the palette) over the engine's own find

Host: `tab_find {id, action, query}` (shell-only; start, next, previous, stop). On Windows it drives WebView2's own find (`ICoreWebView2Find` from `ICoreWebView2_28`, find options from `ICoreWebView2Environment15`; its built-in find bar suppressed, all matches highlighted) and reports the engine's match count and active match to the shell as `tab-find` events. On macOS, Linux and an older WebView2 Runtime native code evaluates the engine's `window.find` in the tab instead (next and previous work, no count); WKWebView's and WebKitGTK's native find are not wired here because this tree cannot be compiled or run for them on this machine. The query is one typed line (1..=256 characters, no control characters), goes to the engine and is kept nowhere. Tabs gain no command: a tab forwards the existing key name `find` (Ctrl+F) through tab_shortcut.

Frame: FindBar.tsx takes the address field's place in its pane's toolbar while open, so the page does not move and in split view the bar is the searched pane's. "3/17" where the engine counts, "No matches" for none; Enter / F3 next, Shift+Enter / Shift+F3 previous, Esc closes, clears the highlights and gives the page the keyboard. `features.find` (on by default) gates the menu row, the palette entry and the keys. Only what the page has rendered can be found, as in a browser.

Also: this makes `pub mod find` in lib.rs (already in HEAD) resolve again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 22:34:48 +07:00
parent 832396801c
commit b31fa6b1d2
17 changed files with 449 additions and 19 deletions

View file

@ -145,6 +145,115 @@ pub fn forget_tab_zoom<R: Runtime>(app: &AppHandle<R>, id: &str) {
}
}
/// `tab_find`: searches tab `id`'s page ([`crate::find`]). The engine's own find where this build
/// can drive it (WebView2's `ICoreWebView2Find`, which reports counts through `tab-find` events);
/// otherwise the evaluated `window.find` fallback. The query is kept nowhere.
pub fn tab_find<R: Runtime>(app: &AppHandle<R>, id: &str, action: &str, query: Option<&str>) -> Result<(), String> {
if !policy::valid_tab_id(id) {
return Err("VALIDATION: invalid tab id".into());
}
let request = crate::find::request(action, query)?;
let tab = tab_webview(app, id).ok_or("TAB: not found")?;
#[cfg(windows)]
{
let fallback = tab.clone();
let (app, id) = (app.clone(), id.to_owned());
tab.with_webview(move |platform| {
// The engine has no find interface (an older WebView2 Runtime): the evaluated fallback.
if !find_windows::run(&app, &platform, &id, &request) {
let _ = fallback.eval(crate::find::fallback_script(&request));
}
})
.map_err(|error| format!("TAB: {error}"))
}
#[cfg(not(windows))]
{
tab.eval(crate::find::fallback_script(&request)).map_err(|error| format!("TAB: {error}"))
}
}
/// WebView2's own find (`ICoreWebView2Find`, WebView2 Runtime with `ICoreWebView2_28`): the find
/// session, its highlights and its counts are the engine's; its own find bar stays hidden.
#[cfg(windows)]
mod find_windows {
use super::{emit_shell, AppHandle, Runtime};
use crate::find::{FindAction, FindRequest, FindState, EVENT_TAB_FIND};
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment15, ICoreWebView2Find, ICoreWebView2_28};
use webview2_com::{FindActiveMatchIndexChangedEventHandler, FindMatchCountChangedEventHandler, FindStartCompletedHandler};
use windows::core::{Interface, HSTRING};
/// Tab webviews whose find object already reports its counts (one pair of handlers each).
fn watched() -> std::sync::MutexGuard<'static, HashSet<String>> {
static WATCHED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
WATCHED.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// The tab's webview is gone: a new one under the same id registers its own handlers.
pub(super) fn forget(id: &str) {
watched().remove(id);
}
fn report<R: Runtime>(app: &AppHandle<R>, id: &str, find: &ICoreWebView2Find) {
let (mut count, mut index) = (0i32, -1i32);
// SAFETY: COM property reads on the live find object, on the main thread.
unsafe {
let _ = find.MatchCount(&mut count);
let _ = find.ActiveMatchIndex(&mut index);
}
emit_shell(app, EVENT_TAB_FIND, FindState { id: id.to_owned(), count, index });
}
/// Runs `request` with the engine's find on the main thread; false when this runtime has none.
pub(super) fn run<R: Runtime>(app: &AppHandle<R>, platform: &tauri::webview::PlatformWebview, id: &str, request: &FindRequest) -> bool {
// SAFETY: COM calls on this webview's live controller and environment, on the main thread
// (with_webview runs the closure there; the handlers run there too).
unsafe {
let Ok(core) = platform.controller().CoreWebView2() else { return false };
let (Ok(core), Ok(environment)) = (core.cast::<ICoreWebView2_28>(), platform.environment().cast::<ICoreWebView2Environment15>()) else { return false };
let Ok(find) = core.Find() else { return false };
if watched().insert(id.to_owned()) {
let mut token = 0i64;
let (count_app, count_id) = (app.clone(), id.to_owned());
let counted = FindMatchCountChangedEventHandler::create(Box::new(move |sender, _| {
if let Some(find) = sender { report(&count_app, &count_id, &find); }
Ok(())
}));
let _ = find.add_MatchCountChanged(&counted, &mut token);
let (index_app, index_id) = (app.clone(), id.to_owned());
let moved = FindActiveMatchIndexChangedEventHandler::create(Box::new(move |sender, _| {
if let Some(find) = sender { report(&index_app, &index_id, &find); }
Ok(())
}));
let _ = find.add_ActiveMatchIndexChanged(&moved, &mut token);
}
match (request.action, request.query.as_deref()) {
(FindAction::Start, Some(query)) => {
let Ok(options) = environment.CreateFindOptions() else { return false };
let _ = options.SetFindTerm(&HSTRING::from(query));
let _ = options.SetSuppressDefaultFindDialog(true);
let _ = options.SetShouldHighlightAllMatches(true);
let _ = options.SetIsCaseSensitive(false);
let _ = options.SetShouldMatchWord(false);
// A new query is a new session, from the first match.
let _ = find.Stop();
let (done_app, done_id, done_find) = (app.clone(), id.to_owned(), find.clone());
let started = FindStartCompletedHandler::create(Box::new(move |_| {
report(&done_app, &done_id, &done_find);
Ok(())
}));
let _ = find.Start(&options, &started);
}
(FindAction::Next, _) => { let _ = find.FindNext(); }
(FindAction::Previous, _) => { let _ = find.FindPrevious(); }
(FindAction::Stop, _) | (FindAction::Start, None) => { let _ = find.Stop(); }
}
}
true
}
}
/// A browser shortcut pressed inside tab `id`, forwarded to the shell (`tab-shortcut`).
pub fn forward_shortcut<R: Runtime>(app: &AppHandle<R>, id: String, shortcut: String) -> Result<(), String> {
if !tabs::valid_shortcut(&shortcut) {
@ -803,6 +912,11 @@ pub fn forget_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
}
#[cfg(not(windows))]
let _ = app;
// Its find session went with the webview: a new one under the same id registers its own handlers.
#[cfg(windows)]
if let Some(id) = policy::tab_id(label) {
find_windows::forget(id);
}
placements().forget(label);
}