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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 10:45:23 -04:00 committed by GitHub
parent cd6b2bbe46
commit 3e7f355e5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 333 additions and 42 deletions

View File

@ -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) |
|---|---|

View File

@ -114,12 +114,25 @@ pub fn specs() -> Vec<CommandSpec> {
.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| {

View File

@ -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(&reg, "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");
}

View File

@ -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::<u64>() {
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<String> = 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<Value> = 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<Value> {
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::<i64>().ok().filter(|n| *n >= 0).map(|n| json!({"pageBy": n}));
}
if t.starts_with('-') {
return t.parse::<i64>().ok().map(|n| json!({"pageBy": n}));
}
t.parse::<u64>().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}));

View File

@ -172,7 +172,7 @@ fn controls(tab: &str) -> Vec<Control> {
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"),

View File

@ -371,6 +371,8 @@ pub struct WordApp {
pub(crate) synthetic: Vec<egui::Event>,
/// 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<WordApp>| h.ctx.data(|d| d.get_temp::<String>(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"));
}
}

View File

@ -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::<String>(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}));

View File

@ -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"}));
});

View File

@ -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 |