* 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:
parent
cd6b2bbe46
commit
3e7f355e5a
|
|
@ -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) |
|
||||
|---|---|
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue