From 3e7f355e5ad911cf0d091fb9ce2f155d432033bf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 10:45:23 -0400 Subject: [PATCH] Ctrl+F searches in the Navigation pane; one Find and Replace dialog (#712) (#921) * Find, Replace and Go To work like Word (#712) Before: Ctrl+F opened both the Navigation pane and a Find window, Advanced Find opened the same window, Ctrl+H failed with nothing to replace, and Go To was a separate little window that only took a page. After, as in Word: - Ctrl+F and Home > Find open only the Navigation pane, with its search box focused so typing searches; selected text is searched for. - Find, Replace and Go To are one Find and Replace dialog with three tabs: Advanced Find opens Find, Ctrl+H (edit.findReplace, which never marks the document edited) opens Replace, Ctrl+G / F5 open Go To. - Go To moves to a page (a number, or +2 / -1 from the page the caret is on; empty, Next and Previous step a page) or picks a bookmark. edit.goto takes pageBy for scripts and agents. Co-Authored-By: Claude Opus 5.5 (1M context) * Go To: list bookmarks only while Bookmark is chosen (#712) Finding a document's bookmarks walks every paragraph; the Go To tab did it every frame even while it showed pages. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 29 ++++- crates/engine/src/tests.rs | 48 ++++++++ crates/ui-egui/src/dialogs.rs | 201 ++++++++++++++++++++++++++++------ crates/ui-egui/src/keytips.rs | 2 +- crates/ui-egui/src/lib.rs | 72 +++++++++++- crates/ui-egui/src/panes.rs | 18 ++- crates/ui-egui/src/ribbon.rs | 3 +- docs/ui-parity.md | 1 + 9 files changed, 333 insertions(+), 42 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c6c46e0..0d7ac42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -165,6 +165,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges | | 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` | | 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents | +| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d8b78a5..ebc8c0e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -114,12 +114,25 @@ pub fn specs() -> Vec { .key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp")) .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) .pure(), - CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#), + CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#), + // Ctrl+H, Home › Replace: the Find and Replace dialog on its Replace tab (#712). Opening + // it changes nothing, so it is its own command and never marks the document edited. + CommandSpec::new("edit.findReplace", "Find and Replace", "Home › Editing", |s, v| { + let tab = match p::str(v, "tab").unwrap_or("replace") { + t @ ("find" | "replace" | "goto") => t, + other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))), + }; + s.ui_requests.push(json!({"open": tab})); + Ok(json!({"open": tab})) + }) + .key("Mod+H") + .params(r#"{"tab"?: "find|replace|goto" (default replace)}"#) + .pure(), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto) .key("Mod+Alt+G / F5") - .params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#) + .params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#) .pure(), CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| { let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?; @@ -521,8 +534,10 @@ fn read_opts(s: &mut Session, v: &Value) { fn find(s: &mut Session, v: &Value) -> CmdResult { read_opts(s, v); if p::str(v, "text").is_none() { + // Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the + // Find and Replace dialog is Advanced Find. s.view.nav_pane = true; - s.ui_requests.push(json!({"open": "find"})); + s.ui_requests.push(json!({"focus": "navSearch"})); return Ok(json!({"count": s.find.results.len()})); } let story = s.sel.focus.story; @@ -633,7 +648,13 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult { } fn goto(s: &mut Session, v: &Value) -> CmdResult { - if let Some(pg) = p::u64(v, "page") { + // Relative pages, Go To's "+2" / "-1": from the page the caret is on. + let by = v.get("pageBy").and_then(Value::as_i64); + let rel = by.map(|by| { + let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64; + (here + 1).saturating_add(by).max(1) as u64 + }); + if let Some(pg) = rel.or_else(|| p::u64(v, "page")) { let l = s.layout(); let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1)); let target = l.pages.get(idx).and_then(|page| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cd1e41f..ba7bfaa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4736,3 +4736,51 @@ fn spacing_commands_handle_automatic_spacing() { assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space"); assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic"); } + +/// #712, as in Word: Ctrl+F (`edit.find` with no text) opens the Navigation pane and asks the UI +/// to put the keyboard in its search box, not the dialog; Ctrl+H (`edit.replace` with nothing to +/// replace) opens Find and Replace on its Replace tab instead of failing; Go To moves by pages from +/// the caret's page (`pageBy`). +#[test] +fn find_replace_and_go_to_open_like_word() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "cat dog cat"})); + run(&mut s, "edit.find", json!({})); + assert!(s.view.nav_pane); + assert_eq!(s.ui_requests.last(), Some(&json!({"focus": "navSearch"}))); + assert!(!s.ui_requests.iter().any(|r| r.get("open").is_some()), "no dialog: {:?}", s.ui_requests); + s.dirty = false; + run(&mut s, "edit.findReplace", json!({})); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "replace"}))); + assert!(!s.dirty, "opening the dialog changes nothing"); + assert!(s.run("edit.findReplace", &json!({"tab": "nope"})).is_err()); + let reg = s.registry.clone(); + assert_eq!(s.keymap.resolve(®, "Mod+H").map(|c| c.id), Some("edit.findReplace")); + run(&mut s, "edit.goto", json!({})); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "goto"}))); + // Replace with a "with" and the last search still replaces, as before. + run(&mut s, "edit.find", json!({"text": "dog"})); + run(&mut s, "edit.replace", json!({"with": "cow"})); + assert_eq!(text(&s), "cat cow cat"); + + // Five pages; Go To +2 / -1 from where the caret is, clamped to the document. + let mut s = super::tests::s(); + for i in 0..5 { + if i > 0 { + run(&mut s, "insert.pageBreak", json!({})); + } + run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)})); + } + let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1; + run(&mut s, "edit.goto", json!({"page": 1})); + assert_eq!(page(&mut s), 1); + run(&mut s, "edit.goto", json!({"pageBy": 2})); + assert_eq!(page(&mut s), 3); + run(&mut s, "edit.goto", json!({"pageBy": -1})); + assert_eq!(page(&mut s), 2); + run(&mut s, "edit.goto", json!({"pageBy": 99})); + assert_eq!(page(&mut s), 5, "past the end: the last page"); + run(&mut s, "edit.goto", json!({"pageBy": -99})); + assert_eq!(page(&mut s), 1, "before the start: the first page"); + assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dce98ee..edff600 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -31,6 +31,25 @@ pub struct FindExtra { pub to_replace: bool, } +/// The tabs of the Find and Replace dialog (#712). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FindTab { + #[default] + Find, + Replace, + GoTo, +} + +/// What Go To moves to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GoToWhat { + #[default] + Page, + Bookmark, +} + /// The Special ▾ menu: (name, code) for Find what, without and with wildcards. const SPECIAL_FIND: &[(&str, &str)] = &[ ("Paragraph Mark", "^p"), @@ -202,14 +221,15 @@ pub enum Dialog { regex: bool, /// Search options under "Search Options" (#543). extra: FindExtra, - replace_mode: bool, + /// Find, Replace or Go To, one dialog as in Word (#712). + tab: FindTab, + goto_what: GoToWhat, + /// Go To's box: a page number (`+2`, `-1` move from the current page) or a bookmark. + goto_target: String, message: String, /// Put the caret in Find what on the first frame, so typing searches (#772). focus: bool, }, - Goto { - page: String, - }, InsertTable { rows: u32, cols: u32, @@ -852,9 +872,9 @@ impl Dialog { Dialog::Paragraph { .. } => "paragraph", Dialog::Tabs { .. } => "tabs", Dialog::Borders { .. } => "borders", - Dialog::Find { replace_mode: false, .. } => "find", - Dialog::Find { .. } => "replace", - Dialog::Goto { .. } => "goto", + Dialog::Find { tab: FindTab::Find, .. } => "find", + Dialog::Find { tab: FindTab::Replace, .. } => "replace", + Dialog::Find { tab: FindTab::GoTo, .. } => "goto", Dialog::InsertTable { .. } => "insertTable", Dialog::PageSetup { .. } => "pageSetup", Dialog::Link { .. } => "link", @@ -1010,7 +1030,7 @@ impl Dialog { }; Dialog::Borders { form: Box::new(crate::dialogs_para::BordersForm::read(app, tab)?) } } - "find" | "replace" => Dialog::Find { + "find" | "replace" | "goto" => Dialog::Find { query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() }, replace: app.session.find.replace.clone(), match_case: app.session.find.match_case, @@ -1029,11 +1049,16 @@ impl Dialog { || app.session.find.ignore_space, to_replace: false, }, - replace_mode: name == "replace", + tab: match name { + "replace" => FindTab::Replace, + "goto" => FindTab::GoTo, + _ => FindTab::Find, + }, + goto_what: GoToWhat::Page, + goto_target: String::new(), message: String::new(), focus: true, }, - "goto" => Dialog::Goto { page: String::new() }, "insertTable" => Dialog::InsertTable { rows: 2, cols: 5 }, "pageSetup" => { let sp = wordcraft_engine::cmd::page::sect(&app.session); @@ -1298,9 +1323,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Paragraph { .. } => "Paragraph", Dialog::Tabs { .. } => "Tabs", Dialog::Borders { .. } => "Borders and Shading", - Dialog::Find { replace_mode: false, .. } => "Find", Dialog::Find { .. } => "Find and Replace", - Dialog::Goto { .. } => "Go To", Dialog::InsertTable { .. } => "Insert Table", Dialog::PageSetup { .. } => "Page Setup", Dialog::Link { .. } => "Insert Hyperlink", @@ -1670,15 +1693,20 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form), Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form), - Dialog::Find { query, replace, match_case, whole_word, regex, extra, replace_mode, message, focus } => { + Dialog::Find { query, replace, match_case, whole_word, regex, extra, tab, goto_what, goto_target, message, focus } => { ui.horizontal(|ui| { - if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() { - *replace_mode = false; - } - if ui.selectable_label(*replace_mode, tl!("Replace")).clicked() { - *replace_mode = true; + for (t, label) in [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")] { + if ui.selectable_label(*tab == t, tl!(label)).clicked() && *tab != t { + *tab = t; + message.clear(); + } } }); + ui.separator(); + if *tab == FindTab::GoTo { + return goto_body(app, ui, goto_what, goto_target, message); + } + let replace_mode = *tab == FindTab::Replace; ui.horizontal(|ui| { ui.label(tl!("Find what:")); let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new("find_what")).desired_width(240.0)); @@ -1689,7 +1717,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { extra.to_replace = false; } }); - if *replace_mode { + if replace_mode { ui.horizontal(|ui| { ui.label(tl!("Replace with:")); if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() { @@ -1738,7 +1766,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { "ignorePunct": extra.ignore_punct, "ignoreSpace": extra.ignore_space, }); - if !*replace_mode { + if !replace_mode { ui.horizontal(|ui| { let lit = app.session.find.highlight; if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() { @@ -1757,7 +1785,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } let mut close = false; ui.horizontal(|ui| { - if *replace_mode { + if replace_mode { if ui.button(tl!("Replace All")).clicked() { match app.run("edit.replaceAll", opts.clone()) { Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]), @@ -1781,17 +1809,6 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); close } - Dialog::Goto { page } => { - ui.horizontal(|ui| { - ui.label(tl!("Enter page number:")); - ui.text_edit_singleline(page); - }); - let (ok, cancel) = buttons(ui, tl!("Go To")); - if ok && let Ok(n) = page.trim().parse::() { - let _ = app.run("edit.goto", json!({"page": n})); - } - ok || cancel - } Dialog::Enclose { style, text, shape } => { ui.label(egui::RichText::new(tl!("Style")).font(semibold(12.5))); ui.horizontal(|ui| { @@ -3048,6 +3065,101 @@ fn command_matches(c: &wordcraft_engine::CommandSpec, query: &str) -> bool { query.split_whitespace().all(|w| hay.contains(&w.to_lowercase()) || hay.contains(&crate::i18n::lowercase(w))) } +/// The Find and Replace dialog's Go To tab (#712): a page (a number, or `+2` / `-1` from the +/// current page; empty, Next and Previous step a page) or a bookmark. Returns true to close. +fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut String, message: &mut String) -> bool { + // The document's bookmarks, only while they are listed (finding them walks the document). + let bookmarks: Vec = if *what == GoToWhat::Bookmark { + app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect() + } else { + Vec::new() + }; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(tl!("Go to what:")); + for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] { + if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w { + *what = w; + target.clear(); + message.clear(); + } + } + }); + ui.add_space(16.0); + ui.vertical(|ui| match what { + GoToWhat::Page => { + ui.label(tl!("Enter page number:")); + ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).request_focus(); + ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak()); + } + GoToWhat::Bookmark => { + ui.label(tl!("Enter bookmark name:")); + egui::ComboBox::from_id_salt("goto_bookmark").width(220.0).selected_text(target.as_str()).show_ui(ui, |ui| { + for b in &bookmarks { + ui.selectable_value(target, b.clone(), b.as_str()); + } + }); + if bookmarks.is_empty() { + ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak()); + } + } + }); + }); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).weak()); + } + let mut close = false; + let mut go: Option = None; + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(8.0); + ui.horizontal(|ui| { + let page_step = *what == GoToWhat::Page && target.trim().is_empty(); + if page_step && ui.button(tl!("Previous")).clicked() { + go = Some(json!({"pageBy": -1})); + } + let label = if page_step { tl!("Next") } else { tl!("Go To") }; + if ui.button(label).clicked() || enter { + go = match what { + GoToWhat::Page => match goto_page(target) { + Some(p) => Some(p), + None => { + *message = tl!("Type a page number, or +2 / -1.").to_string(); + None + } + }, + GoToWhat::Bookmark if !target.is_empty() => Some(json!({"bookmark": target.clone()})), + GoToWhat::Bookmark => None, + }; + } + if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + }); + if let Some(params) = go { + match app.run("edit.goto", params) { + Ok(_) => message.clear(), + Err(e) => *message = e, + } + } + close +} + +/// Go To's page box as `edit.goto` params: empty steps one page on, `+n` / `-n` move from the +/// current page, a number is that page. `None` for anything else. +fn goto_page(text: &str) -> Option { + let t = text.trim().replace('\u{2212}', "-"); + if t.is_empty() { + return Some(json!({"pageBy": 1})); + } + if let Some(rest) = t.strip_prefix('+') { + return rest.parse::().ok().filter(|n| *n >= 0).map(|n| json!({"pageBy": n})); + } + if t.starts_with('-') { + return t.parse::().ok().map(|n| json!({"pageBy": n})); + } + t.parse::().ok().filter(|n| *n >= 1).map(|n| json!({"page": n})) +} + /// path data; Insert (or a double click) runs `insert.icon`. Returns true to close. fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool { use wordcraft_render::icon_lib::{self, Category}; @@ -3160,6 +3272,31 @@ mod tests { use wordcraft_doc::props::Align; use wordcraft_engine::Session; + /// Go To's page box (#712): a number, +n / -n from the current page (a typographic minus + /// too), empty for the next page; anything else is refused. + #[test] + fn go_to_reads_its_page_box() { + assert_eq!(goto_page("7"), Some(json!({"page": 7}))); + assert_eq!(goto_page(" +2 "), Some(json!({"pageBy": 2}))); + assert_eq!(goto_page("-1"), Some(json!({"pageBy": -1}))); + assert_eq!(goto_page("\u{2212}3"), Some(json!({"pageBy": -3}))); + assert_eq!(goto_page(""), Some(json!({"pageBy": 1}))); + for junk in ["0", "abc", "+x", "1.5", "99999999999999999999999"] { + assert_eq!(goto_page(junk), None, "{junk}"); + } + } + + /// Find, Replace and Go To are one dialog with three tabs (#712). + #[test] + fn find_replace_and_go_to_are_one_dialog() { + let mut app = app_with("One"); + for (name, tab) in [("find", FindTab::Find), ("replace", FindTab::Replace), ("goto", FindTab::GoTo)] { + let _ = app.run("ui.dialog", json!({"name": name})); + assert!(matches!(&app.dialog, Some(Dialog::Find { tab: t, .. }) if *t == tab), "{name}"); + assert_eq!(app.dialog.as_ref().map(|d| d.name()), Some(name)); + } + } + fn app_with(text: &str) -> WordApp { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); let _ = app.run("document.setText", json!({"text": text})); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 19cafe8..c50ebd4 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -172,7 +172,7 @@ fn controls(tab: &str) -> Vec { m("Line and Paragraph Spacing", "para.lineSpacing"), m("Shading", "para.shading"), m("Borders", "para.borders"), - d("Find", "find"), + c("Find", "edit.find"), d("Replace", "replace"), m("Select", "select.all"), c("Dictate", "tools.dictate"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 990f75b..3347a3a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -371,6 +371,8 @@ pub struct WordApp { pub(crate) synthetic: Vec, /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). pub(crate) keytip_rects: Vec<(egui::Rect, String)>, + /// The Navigation pane's search box takes the keyboard on its next frame (Ctrl+F, #712). + pub(crate) nav_search_focus: bool, styled: bool, /// The CJK face order the installed UI fonts use (Chinese first, or Japanese first). fonts_hans: bool, @@ -472,6 +474,7 @@ impl WordApp { pending_shots: Vec::new(), synthetic: Vec::new(), keytip_rects: Vec::new(), + nav_search_focus: false, styled: false, fonts_hans: false, fonts_system_cjk: false, @@ -867,6 +870,10 @@ impl WordApp { /// Requests commands make of the UI (open a dialog…). fn handle_request(&mut self, req: &Value) { + // Ctrl+F: type straight into the Navigation pane's search box (#712). + if req.get("focus").and_then(Value::as_str) == Some("navSearch") { + self.nav_search_focus = true; + } if let Some(what) = req.get("open").and_then(Value::as_str) { match what { "openFile" => self.open_dialog(), @@ -2019,7 +2026,9 @@ mod tests { assert_eq!((style.as_str(), shape.as_str()), ("enlarge", "square")); } - /// Issue #772: Ctrl/⌘+F left focus on the document, so typing didn't go into Find what. + /// Issue #772: the Find and Replace dialog left focus on the document, so typing didn't go into + /// Find what. (Ctrl/⌘+F now searches in the Navigation pane, as in Word (#712), so this opens + /// the dialog with Ctrl/⌘+H.) #[test] fn find_shortcut_focuses_find_what() { let ctx = egui::Context::default(); @@ -2036,7 +2045,7 @@ mod tests { for _ in 0..3 { frame(&mut a, Vec::new()); } - let key = egui::Event::Key { key: egui::Key::F, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + let key = egui::Event::Key { key: egui::Key::H, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; frame(&mut a, vec![key]); for _ in 0..2 { frame(&mut a, Vec::new()); @@ -4178,4 +4187,63 @@ mod tests { assert!(h.state().canvas.context_menu_open); assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire"); } + + /// #712: Find, Replace and Go To open as in Word. Ctrl+F puts the keyboard in the Navigation + /// pane's search box (with the selected text in it), not a dialog; Ctrl+H and Ctrl+G / F5 open + /// the one Find and Replace dialog on its Replace and Go To tabs. + #[test] + fn find_replace_and_go_to_keys_like_word() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + for _ in 0..6 { + h.step(); + } + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); + h.step(); + let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::new("nav_query"))).unwrap_or_default(); + // Ctrl+F: the pane, and typing goes into its search box. + h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); + h.step(); + h.step(); + assert!(h.state().session.view.nav_pane, "the Navigation pane opened"); + assert!(h.state().dialog.is_none(), "no dialog: {:?}", h.state().dialog.as_ref().map(|d| d.name())); + h.event(key(egui::Key::C, egui::Modifiers::NONE)); + h.event(egui::Event::Text("cool".into())); + h.step(); + h.step(); + assert_eq!(nav_query(&h), "cool", "typed into the pane's search box"); + assert_eq!(h.state().session.find.results.len(), 1); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire clay. Kilns cool."); + // With text selected, Ctrl+F searches for it. + h.state_mut().run("select.text", json!({"text": "Kilns"})).unwrap(); + h.state_mut().canvas.want_focus = true; + h.step(); + h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); + h.step(); + h.step(); + assert_eq!(nav_query(&h), "Kilns"); + assert_eq!(h.state().session.find.results.len(), 2); + // Ctrl+H: the Replace tab; Go To: the Go To tab of the same dialog. + h.state_mut().canvas.want_focus = true; + h.event(key(egui::Key::Escape, egui::Modifiers::NONE)); + h.step(); + h.step(); + h.event(key(egui::Key::H, egui::Modifiers::COMMAND)); + h.step(); + h.step(); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("replace")); + h.state_mut().run("edit.goto", json!({})).unwrap(); + h.step(); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("goto")); + h.state_mut().run("edit.advancedFind", json!({})).unwrap(); + h.step(); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("find")); + } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index b4aa362..6c1dad2 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -111,8 +111,22 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { } let qid = egui::Id::new("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search document")).desired_width(f32::INFINITY)); - if r.changed() { + // Ctrl+F with text selected searches for it, as Word does (a selection within one line). + let focus = std::mem::take(&mut app.nav_search_focus); + let picked = focus.then(|| app.session.selected_text()).filter(|t| !t.is_empty() && !t.contains(['\n', '\r']) && t.chars().count() <= 255); + if let Some(t) = &picked { + q = t.clone(); + } + let mut out = egui::TextEdit::singleline(&mut q).hint_text(tl!("Search document")).desired_width(f32::INFINITY).show(ui); + let r = out.response.clone(); + if focus { + // Focused with its text selected, so typing replaces it. + r.request_focus(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(q.chars().count())); + out.state.cursor.set_char_range(Some(all)); + out.state.store(ui.ctx(), r.id); + } + if r.changed() || picked.is_some() { ui.data_mut(|d| d.insert_temp(qid, q.clone())); if !q.is_empty() { let _ = app.session.run("edit.find", &json!({"text": q})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5055213..f558bd7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -738,7 +738,8 @@ fn home(app: &mut WordApp, ui: &mut Ui) { group(ui, "Editing", None, app, |ui, app| { stack(ui, |ui| { menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| { - mi(ui, app, "Find", "ui.dialog", json!({"name": "find"})); + // As in Word: Find searches in the Navigation pane; Advanced Find is the dialog (#712). + mi(ui, app, "Find", "edit.find", json!({})); mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({})); mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"})); }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b14706e..deeaef2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -62,6 +62,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-12 | trivial | Keytips use Word for Windows' tab letters, as observed there: File F (opens the Backstage), Design G, References S, Review R, View W, Help Y (were L, R, S, V and none) | | 2026-10-12 | trivial | Shift+F10 opens the page's context menu below the caret, with the spelling suggestions and synonyms for the word there; `ui.contextMenu` for agents (#821). The Menu key itself isn't passed on by the windowing layer yet | | 2026-10-12 | trivial | Search: Alt+Q (and Alt, Q) opens the Search box from anywhere, as in Word; the key's own "q" isn't typed; every word of a query matches, so "insert table" finds Insert › Tables › Table; `tools.search` lists matches for agents (#819) | +| 2026-10-12 | trivial | Find, Replace and Go To as in Word (#712): Ctrl+F and Home › Find open only the Navigation pane with its search box ready to type in (the selected text searched for); Find and Replace is one dialog with Find, Replace and Go To tabs (Advanced Find, Ctrl+H, Ctrl+G / F5); Go To moves to a page (a number, or +2 / −1 from the current page; Next and Previous step a page) or a bookmark | | 2026-10-11 | trivial | Tables on the page: column and row borders and the resize handle drag, with a dashed guide and one undo step per drag (#217) | | 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart | | 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents |