macOS find: page matches drawn with the CSS Custom Highlight API (window.find showed nothing while the page was not the key view); --diag logs exit requests, window close/destroy and each native pane placement on macOS
This commit is contained in:
parent
6d6e6c5b62
commit
b13849da99
4 changed files with 113 additions and 36 deletions
|
|
@ -699,7 +699,7 @@ async fn tab_zoom(webview: Webview, app: AppHandle, id: String, factor: Option<f
|
||||||
|
|
||||||
/// Find in page for tab `id` (the shell's find bar): `start` with a query, `next`, `previous`, `stop`.
|
/// Find in page for tab `id` (the shell's find bar): `start` with a query, `next`, `previous`, `stop`.
|
||||||
/// The engine's own find where this build can drive it (counts arrive as `tab-find` events), else the
|
/// The engine's own find where this build can drive it (counts arrive as `tab-find` events), else the
|
||||||
/// evaluated `window.find`. The query is one typed line (validated) and is kept nowhere.
|
/// evaluated Custom Highlight script. The query is one typed line (validated) and is kept nowhere.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn tab_find(webview: Webview, app: AppHandle, id: String, action: String, query: Option<String>) -> Result<(), String> {
|
async fn tab_find(webview: Webview, app: AppHandle, id: String, action: String, query: Option<String>) -> Result<(), String> {
|
||||||
shell_only(&webview)?;
|
shell_only(&webview)?;
|
||||||
|
|
@ -1454,8 +1454,22 @@ impl App {
|
||||||
handler(invoke)
|
handler(invoke)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(context)
|
.build(context)
|
||||||
.unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title));
|
.unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title))
|
||||||
|
.run(|_, event| log_run_event(&event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the app is going away, for `--diag` ([`crate::diag`]): an exit request with its code (None is a
|
||||||
|
/// polite quit from outside: Cmd+Q, the Dock, or another app such as an auto-quitter closing the last
|
||||||
|
/// window), window close/destroy events and the final exit. Nothing is written without diagnostics.
|
||||||
|
fn log_run_event(event: &tauri::RunEvent) {
|
||||||
|
match event {
|
||||||
|
tauri::RunEvent::ExitRequested { code, .. } => crate::diag::log("host", &format!("exit requested code={code:?}")),
|
||||||
|
tauri::RunEvent::Exit => crate::diag::log("host", "exit"),
|
||||||
|
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { .. }, .. } => crate::diag::log("host", &format!("window {label} close requested")),
|
||||||
|
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => crate::diag::log("host", &format!("window {label} destroyed")),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! (Windows, WebView2 Runtime with `ICoreWebView2_28`; its own find bar suppressed), which also
|
//! (Windows, WebView2 Runtime with `ICoreWebView2_28`; its own find bar suppressed), which also
|
||||||
//! reports the match count and the active match (`tab-find` events to the shell). Everywhere else
|
//! reports the match count and the active match (`tab-find` events to the shell). Everywhere else
|
||||||
//! (macOS, Linux, an older WebView2 Runtime) native code evaluates [`fallback_script`] in the tab:
|
//! (macOS, Linux, an older WebView2 Runtime) native code evaluates [`fallback_script`] in the tab:
|
||||||
//! the engine's `window.find`, which selects the next match and scrolls to it but counts nothing.
|
//! the page's matches drawn with the CSS Custom Highlight API (see [`fallback_script`]), which counts nothing.
|
||||||
//!
|
//!
|
||||||
//! The query is data: it goes from the shell to the engine and is kept nowhere (no file, no log,
|
//! The query is data: it goes from the shell to the engine and is kept nowhere (no file, no log,
|
||||||
//! no state in this crate). Tabs gain no command; a tab may only forward the key name `find`
|
//! no state in this crate). Tabs gain no command; a tab may only forward the key name `find`
|
||||||
|
|
@ -86,23 +86,79 @@ pub fn request(action: &str, query: Option<&str>) -> Result<FindRequest, String>
|
||||||
Ok(FindRequest { action, query: if action == FindAction::Stop { None } else { query } })
|
Ok(FindRequest { action, query: if action == FindAction::Stop { None } else { query } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The script native code evaluates where the engine's find cannot be driven natively: the
|
/// The script native code evaluates where the engine's find cannot be driven natively (macOS,
|
||||||
/// engine's `window.find` (case-insensitive, wrapping), or clearing the selection for `stop`.
|
/// Linux, an old WebView2): the page's matches drawn with the CSS Custom Highlight API (every match
|
||||||
/// The query appears only as a JSON string literal; nothing is sent anywhere or stored in the page.
|
/// tinted, the active one stronger and scrolled into view; independent of window focus, which the
|
||||||
|
/// engine's selection-based `window.find` is not: it shows nothing while the page is not the key
|
||||||
|
/// view), or `window.find` where an engine lacks that API. `stop` removes the highlights.
|
||||||
|
/// The query appears only as a JSON string literal; nothing is sent anywhere or stored: the
|
||||||
|
/// matches live in the page's highlight registry until `stop`, the query nowhere.
|
||||||
pub fn fallback_script(request: &FindRequest) -> String {
|
pub fn fallback_script(request: &FindRequest) -> String {
|
||||||
match (&request.action, &request.query) {
|
let action = match request.action {
|
||||||
(FindAction::Stop, _) | (_, None) => {
|
FindAction::Start => "start",
|
||||||
"(()=>{try{const s=getSelection();if(s)s.removeAllRanges();}catch(e){}})();".to_owned()
|
FindAction::Next => "next",
|
||||||
|
FindAction::Previous => "previous",
|
||||||
|
FindAction::Stop => "stop",
|
||||||
|
};
|
||||||
|
let query = request.query.as_ref().map_or_else(|| "null".to_owned(), |query| serde_json::to_string(query).unwrap_or_else(|_| "null".into()));
|
||||||
|
HIGHLIGHT_SCRIPT.replace("__ACTION__", action).replace("__QUERY__", &query)
|
||||||
}
|
}
|
||||||
(action, Some(query)) => {
|
|
||||||
let text = serde_json::to_string(query).unwrap_or_else(|_| "\"\"".into());
|
const HIGHLIGHT_SCRIPT: &str = r#"(()=>{try{
|
||||||
// A new query starts from the top of the page; next/previous continue from the selection.
|
const action="__ACTION__",query=__QUERY__;
|
||||||
let restart = if *action == FindAction::Start { "const s=getSelection();if(s)s.removeAllRanges();" } else { "" };
|
const H=window.CSS&&CSS.highlights,ALL="wb-find",ACT="wb-find-active";
|
||||||
let backwards = *action == FindAction::Previous;
|
const sheets=()=>document.adoptedStyleSheets||[];
|
||||||
format!("(()=>{{try{{{restart}if(typeof window.find==='function')window.find({text},false,{backwards},true,false,false,false);}}catch(e){{}}}})();")
|
const stop=()=>{if(H){H.delete(ALL);H.delete(ACT);}try{document.adoptedStyleSheets=sheets().filter(s=>!s.__wbFind);}catch(e){}};
|
||||||
|
if(action==="stop"||(action==="start"&&query===null)){stop();return;}
|
||||||
|
if(!H||typeof Highlight!=="function"){
|
||||||
|
if(query!==null&&typeof window.find==="function"){if(action==="start"){const s=getSelection();if(s)s.removeAllRanges();}window.find(query,false,action==="previous",true,false,false,false);}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const live=r=>r.startContainer.isConnected&&r.endContainer.isConnected;
|
||||||
|
const collect=q=>{
|
||||||
|
const needle=q.toLowerCase(),out=[];
|
||||||
|
if(!needle||needle.length!==q.length)return out;
|
||||||
|
const walker=document.createTreeWalker(document.body||document.documentElement,NodeFilter.SHOW_TEXT,{acceptNode:n=>{const p=n.parentElement;return !p||/^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/.test(p.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}});
|
||||||
|
for(let n;(n=walker.nextNode())&&out.length<2000;){
|
||||||
|
const t=n.nodeValue;
|
||||||
|
if(t.length<needle.length)continue;
|
||||||
|
const hay=t.toLowerCase();
|
||||||
|
if(hay.length!==t.length)continue;
|
||||||
|
const el=n.parentElement;
|
||||||
|
if(el.checkVisibility&&!el.checkVisibility())continue;
|
||||||
|
for(let i=hay.indexOf(needle);i>=0&&out.length<2000;i=hay.indexOf(needle,i+needle.length)){
|
||||||
|
const r=document.createRange();r.setStart(n,i);r.setEnd(n,i+needle.length);out.push(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const active=H.get(ACT);
|
||||||
|
const current=active?[...active][0]:null;
|
||||||
|
let all,idx;
|
||||||
|
if(action==="start"){
|
||||||
|
all=collect(query);
|
||||||
|
idx=all.findIndex(r=>r.getBoundingClientRect().bottom>0);
|
||||||
|
if(idx<0)idx=0;
|
||||||
|
}else{
|
||||||
|
all=[...(H.get(ALL)||[])].filter(live);
|
||||||
|
if(!all.length&&query!==null)all=collect(query);
|
||||||
|
const at=current?all.indexOf(current):-1;
|
||||||
|
idx=!all.length?-1:action==="previous"?(at<=0?all.length-1:at-1):(at+1)%all.length;
|
||||||
}
|
}
|
||||||
|
H.set(ALL,new Highlight(...all));
|
||||||
|
if(idx<0||!all.length){H.delete(ACT);}
|
||||||
|
else{
|
||||||
|
const mark=new Highlight(all[idx]);mark.priority=1;H.set(ACT,mark);
|
||||||
|
const box=all[idx].getBoundingClientRect();
|
||||||
|
if(box.top<0||box.bottom>innerHeight){const el=all[idx].startContainer.parentElement;if(el)el.scrollIntoView({block:"center",inline:"nearest"});}
|
||||||
|
}
|
||||||
|
if(!sheets().some(s=>s.__wbFind)){
|
||||||
|
const sheet=new CSSStyleSheet();
|
||||||
|
sheet.replaceSync("::highlight(wb-find){background-color:rgba(255,213,74,.55);color:inherit}::highlight(wb-find-active){background-color:#ff9632;color:#000}");
|
||||||
|
sheet.__wbFind=true;
|
||||||
|
document.adoptedStyleSheets=[...sheets(),sheet];
|
||||||
|
}
|
||||||
|
}catch(e){}})();"#;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
@ -127,19 +183,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_fallback_script_carries_the_query_only_as_a_string_literal() {
|
fn the_fallback_script_carries_the_query_only_as_a_string_literal() {
|
||||||
let script = fallback_script(&request("start", Some("it's \"quoted\" </script> \\ ${x} `y`")).unwrap());
|
let script = fallback_script(&request("start", Some("it's \"quoted\" </script> \\ ${x} `y`")).unwrap());
|
||||||
assert!(script.contains(r#"window.find("it's \"quoted\" </script> \\ ${x} `y`",false,false,true"#), "{script}");
|
assert!(script.contains(r#"const action="start",query="it's \"quoted\" </script> \\ ${x} `y`";"#), "{script}");
|
||||||
assert!(script.contains("removeAllRanges"), "a new query starts from the top");
|
|
||||||
let next = fallback_script(&request("next", Some("a")).unwrap());
|
let next = fallback_script(&request("next", Some("a")).unwrap());
|
||||||
assert!(next.contains(r#"window.find("a",false,false,true"#) && !next.contains("removeAllRanges"));
|
assert!(next.contains(r#"const action="next",query="a";"#));
|
||||||
assert!(fallback_script(&request("previous", Some("a")).unwrap()).contains(r#"window.find("a",false,true,true"#));
|
assert!(fallback_script(&request("previous", Some("a")).unwrap()).contains(r#"action="previous""#));
|
||||||
// Stop (and a step without a query) only clears the selection.
|
// Stop and a step without a query carry no query: stop clears the highlights, a step uses the page's matches.
|
||||||
for request in [request("stop", None).unwrap(), request("next", None).unwrap()] {
|
assert!(fallback_script(&request("stop", None).unwrap()).contains(r#"action="stop",query=null;"#));
|
||||||
let script = fallback_script(&request);
|
assert!(fallback_script(&request("next", None).unwrap()).contains("query=null;"));
|
||||||
assert!(script.contains("removeAllRanges") && !script.contains("window.find"), "{script}");
|
// Nothing leaves the page or stays in it: no network, storage, invoke or markup writes.
|
||||||
}
|
|
||||||
// Nothing leaves the page or stays in it: no network, storage, invoke or DOM writes.
|
|
||||||
for script in [script, next] {
|
for script in [script, next] {
|
||||||
for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval("] {
|
for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval(", "__ACTION__", "__QUERY__"] {
|
||||||
assert!(!script.contains(forbidden), "{forbidden} in {script}");
|
assert!(!script.contains(forbidden), "{forbidden} in {script}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ 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
|
/// `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);
|
/// can drive it (WebView2's `ICoreWebView2Find`, which reports counts through `tab-find` events);
|
||||||
/// otherwise the evaluated `window.find` fallback. The query is kept nowhere.
|
/// otherwise the evaluated Custom Highlight fallback ([`crate::find::fallback_script`]). The query is kept nowhere.
|
||||||
pub fn tab_find<R: Runtime>(app: &AppHandle<R>, id: &str, action: &str, query: Option<&str>) -> Result<(), String> {
|
pub fn tab_find<R: Runtime>(app: &AppHandle<R>, id: &str, action: &str, query: Option<&str>) -> Result<(), String> {
|
||||||
if !policy::valid_tab_id(id) {
|
if !policy::valid_tab_id(id) {
|
||||||
return Err("VALIDATION: invalid tab id".into());
|
return Err("VALIDATION: invalid tab id".into());
|
||||||
|
|
@ -451,10 +451,20 @@ pub fn place_child<R: Runtime>(webview: &Webview<R>, x: f64, y: f64, width: f64,
|
||||||
// One native move+resize: separate set_position/set_size calls were two WebView2 `SetBounds`
|
// One native move+resize: separate set_position/set_size calls were two WebView2 `SetBounds`
|
||||||
// (the page re-laid out at the old size, then at the new one) and two main-thread messages.
|
// (the page re-laid out at the old size, then at the new one) and two main-thread messages.
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
let _ = webview.set_bounds(tauri::Rect {
|
let _ = webview.set_bounds(tauri::Rect {
|
||||||
position: tauri::LogicalPosition::new(x, y).into(),
|
position: tauri::LogicalPosition::new(x, y).into(),
|
||||||
size: tauri::LogicalSize::new(width, height).into(),
|
size: tauri::LogicalSize::new(width, height).into(),
|
||||||
});
|
});
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if crate::diag::active() {
|
||||||
|
let actual = webview.bounds().map(|b| {
|
||||||
|
let (p, s) = (b.position.to_logical::<f64>(1.0), b.size.to_logical::<f64>(1.0));
|
||||||
|
format!("{},{} {}x{}", p.x, p.y, s.width, s.height)
|
||||||
|
});
|
||||||
|
crate::diag::log("host", &format!("place {} asked {x},{y} {width}x{height} now {actual:?}", webview.label()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux: the shell webview becomes the base layer of a GtkOverlay filling the window, which
|
/// Linux: the shell webview becomes the base layer of a GtkOverlay filling the window, which
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// pane's toolbar while it is open (so the page under it does not move and, in split view, the bar
|
// pane's toolbar while it is open (so the page under it does not move and, in split view, the bar
|
||||||
// is plainly the searched pane's). Native code does the searching (`tab_find`, shell-only): the
|
// is plainly the searched pane's). Native code does the searching (`tab_find`, shell-only): the
|
||||||
// engine's own find where the build can drive it (WebView2: highlights and a match count, which
|
// engine's own find where the build can drive it (WebView2: highlights and a match count, which
|
||||||
// arrive as `tab-find` events), else the engine's `window.find` evaluated by native code (next and
|
// arrive as `tab-find` events), else a Custom Highlight script evaluated by native code (next and
|
||||||
// previous work, no count). Only what the page has rendered can be found, as in a browser: a
|
// previous work, no count). Only what the page has rendered can be found, as in a browser: a
|
||||||
// virtualized list (X's timeline) holds the posts near the viewport, not the whole feed.
|
// virtualized list (X's timeline) holds the posts near the viewport, not the whole feed.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue