From 03274b3b4fc40328dd168a12d1f32cee7f2ac82f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 10:38:53 -0400 Subject: [PATCH] Alt+Q opens Search like Word (#819) (#919) * Search: Alt+Q opens it from anywhere, like Word (#819) - Alt+Q (and Alt, then Q on the keytips) opens the Search box with its text field focused, from the page or anywhere else; the key's own "q" is never typed into the box or the document. The title-bar box shows its Q keytip and its shortcut in the tooltip. - An empty box suggests common commands; every word of a query has to match (so "insert table" finds Insert > Tables > Table, as the box's own hint promises); Enter runs the top match; "Find ... in the document" opens the Navigation pane on the results. Matching is about 2x faster than before. - tools.search lists matching commands for scripts and agents. Observed in Word for Windows (Microsoft 365): Alt+Q and Alt, Q focus Search with nothing typed; multi-word queries match commands; the list also offers finding the words in the document. Co-Authored-By: Claude Opus 5.5 (1M context) * Search: Bulgarian, Czech, German, French, Hungarian, Italian and Polish strings Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/tools.rs | 30 +++++++ crates/ui-egui/src/chrome.rs | 11 ++- crates/ui-egui/src/dialogs.rs | 135 +++++++++++++++++++++++----- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/keys.rs | 19 +++- crates/ui-egui/src/keytips.rs | 21 +++++ crates/ui-egui/src/lib.rs | 60 ++++++++++++- docs/parity-checklist.md | 7 ++ docs/ui-parity.md | 1 + 28 files changed, 293 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e88f816..894538e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan | | 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-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/catalog.rs b/crates/engine/src/catalog.rs index dffb839..7bc7eba 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -35,6 +35,7 @@ Quick Access|Toolbar|Undo List|edit.undoTo Quick Access|Toolbar|Redo|edit.redo Quick Access|Toolbar|Repeat|edit.repeat Quick Access|Toolbar|AutoSave|file.autosave +Title Bar|Search|Search (Tell Me)|tools.search Home|Clipboard|Paste|edit.paste Home|Clipboard|Paste Keep Text Only|edit.pasteText Home|Clipboard|Paste Merge Formatting|edit.pasteMerge diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 31d8a18..8dcceda 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -378,6 +378,11 @@ pub fn specs() -> Vec { r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#, ) .pure(), + // Word's Search box in the title bar ("Tell me what you want to do"), Alt+Q (#819). + CommandSpec::new("tools.search", "Search", "Title Bar", search_commands) + .key(super::non_mac("Alt+Q")) + .params(r#"{"query"?: string} → {matches: [{id, label, location, shortcut}]} (every word of the query in the label, location or id; at most 50); no query opens the Search box"#) + .pure(), CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(COMPARE_PARAMS), CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS), CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(), @@ -1413,6 +1418,31 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult { Ok(Value::Object(out)) } +/// `tools.search`: the commands whose label, location or id hold every word of `query`, or, +/// with no query, the Search box (`ui_requests` → the UI's command search). +fn search_commands(s: &mut Session, v: &Value) -> CmdResult { + let Some(query) = p::str(v, "query") else { + s.ui_requests.push(json!({"open": "commands"})); + return Ok(json!({"matches": []})); + }; + let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + let matches: Vec = if words.is_empty() { + Vec::new() + } else { + s.registry + .all() + .iter() + .filter(|c| { + let hay = format!("{} {} {}", c.label, c.location, c.id).to_lowercase(); + words.iter().all(|w| hay.contains(w.as_str())) + }) + .take(50) + .map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut})) + .collect() + }; + Ok(json!({"matches": matches})) +} + /// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access /// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does /// what they ask (no dialog). diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c355f8b..90f0fcc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -301,8 +301,15 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { ptrack, t.text_dim, ); - if resp.clicked() { - let _ = app.run("ui.dialog", json!({"name": "commands"})); + if app.ui.keytips == crate::keytips::Phase::Tabs { + app.keytip_rects.push((r, crate::keytips::SEARCH_LETTER.to_string())); + } + let tip = match crate::widgets::shortcut_text(app, "tools.search") { + k if k.is_empty() => tl!("Search").to_string(), + k => format!("{} ({k})", tl!("Search")), + }; + if resp.on_hover_text(tip).clicked() { + let _ = app.run("tools.search", json!({})); } right_start = ui.min_rect().min.x; }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7e067b5..dce98ee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -301,6 +301,10 @@ pub enum Dialog { }, Commands { query: String, + /// Opened by a shortcut that may also send its key's text (Alt+Q's "q", in the same frame + /// or the next): that text isn't typed into the box; the next key pressed ends this (#819). + #[serde(skip)] + swallow: bool, }, /// Columns, Symbol and Field ([`crate::dialogs_insert`], #321). Insert(Box), @@ -1087,7 +1091,7 @@ impl Dialog { let regions = table_regions(app, &id); Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } } - "commands" => Dialog::Commands { query: String::new() }, + "commands" => Dialog::Commands { query: String::new(), swallow: true }, "icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() }, "tableProperties" => { let form = Box::new(TableForm::read(app)?); @@ -2212,35 +2216,44 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f), Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f), - Dialog::Commands { query } => { + Dialog::Commands { query, swallow } => { + if *swallow { + ui.ctx().input_mut(|i| i.events.retain(|e| !swallow_shortcut_text(swallow, e))); + } let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0)); r.request_focus(); - let q = query.to_lowercase(); - let localized_q = crate::i18n::lowercase(query); let reg = app.session.registry.clone(); - let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg - .all() - .iter() - .filter(|c| { - !q.is_empty() - && (c.label.to_lowercase().contains(&q) - || crate::i18n::lowercase(tl!(c.label)).contains(&localized_q) - || c.id.to_lowercase().contains(&q) - || c.location.to_lowercase().contains(&q) - || crate::i18n::lowercase(&crate::i18n::location(c.location)).contains(&localized_q)) - }) - .collect(); + // An empty box suggests a few common commands, as Word's Search does. + let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() { + ui.label(egui::RichText::new(tl!("Suggested")).font(semibold(12.5))); + SEARCH_SUGGESTIONS.iter().filter_map(|id| reg.get(id)).collect() + } else { + reg.all().iter().filter(|c| command_matches(c, query)).collect() + }; hits.truncate(14); - let mut close = false; - for c in hits { + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten(); + for c in &hits { let sc = crate::widgets::shortcut_text(app, c.id); let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location)); if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() { - let _ = app.run(c.id, json!({})); - close = true; + run = Some(c.id); } } - close || ui.input(|i| i.key_pressed(egui::Key::Escape)) + // Search also finds the words in the document (the Navigation pane's results). + let text = query.trim().to_string(); + let mut find = false; + if !text.is_empty() { + ui.separator(); + let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]); + find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty()); + } + if let Some(id) = run { + let _ = app.run(id, json!({})); + } else if find { + find_in_navigation_pane(app, ui.ctx(), &text); + } + run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape)) } Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected), Dialog::PasteSpecial { formats, choice, payload, note } => { @@ -2991,6 +3004,50 @@ fn describe(st: &Value) -> String { parts.join(", ") } +/// What Search lists before anything is typed: common commands (#819). +const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "file.print"]; + +/// Search's "Find … in the document": the Navigation pane opens on its results for `text`. +fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string())); + app.ui.nav_tab = "results".into(); + let _ = app.run("view.navigationPane", json!({"value": true})); + let _ = app.run("edit.find", json!({"text": text})); +} + +/// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`): +/// a plain key press first means the user is typing, and one swallowed text is all there is. +fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool { + match e { + egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => { + *swallow = false; + false + } + egui::Event::Text(_) if *swallow => { + *swallow = false; + true + } + _ => false, + } +} + +/// Search's filter: every word of the query is in the command's label, location or id, in English +/// or in the interface language, so "insert table" finds Insert › Tables › Table (#819). +fn command_matches(c: &wordcraft_engine::CommandSpec, query: &str) -> bool { + if query.trim().is_empty() { + return false; + } + let hay = format!( + "{} {} {} {} {}", + c.label.to_lowercase(), + c.location.to_lowercase(), + c.id.to_lowercase(), + crate::i18n::lowercase(tl!(c.label)), + crate::i18n::lowercase(&crate::i18n::location(c.location)) + ); + query.split_whitespace().all(|w| hay.contains(&w.to_lowercase()) || hay.contains(&crate::i18n::lowercase(w))) +} + /// 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}; @@ -3113,6 +3170,40 @@ mod tests { app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default() } + /// #819: the text a shortcut sends with its key isn't typed into the box it opened, whether it + /// comes in the same frame or the next; real typing (a key press first) is. + #[test] + fn shortcut_text_is_swallowed_once() { + let key = |key, alt| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { alt, ..Default::default() }, + }; + let text = |t: &str| egui::Event::Text(t.into()); + let run = |events: &[egui::Event]| { + let mut swallow = true; + events.iter().filter(|e| !swallow_shortcut_text(&mut swallow, e)).cloned().collect::>() + }; + assert_eq!(run(&[key(egui::Key::Q, true), text("q")]), vec![key(egui::Key::Q, true)], "Alt+Q's q, same frame"); + assert_eq!(run(&[text("q"), text("b")]), vec![text("b")], "the q a frame late; then typing"); + assert_eq!(run(&[key(egui::Key::B, false), text("b")]), vec![key(egui::Key::B, false), text("b")], "a click-opened box takes typing"); + } + + /// #819: Search matches every word anywhere in the label, location or id, in any order. + #[test] + fn command_search_matches_every_word() { + let app = app_with(""); + let reg = app.session.registry.clone(); + let table = reg.get("insert.table").unwrap(); + assert!(command_matches(table, "insert table"), "the box's own example"); + assert!(command_matches(table, " Table INSERT "), "any order, any case, extra spaces"); + assert!(!command_matches(table, "insert chart"), "every word has to match"); + assert!(!command_matches(table, ""), "an empty box lists nothing"); + assert!(!command_matches(table, " ")); + } + /// Ordinary Turkish queries must find dotted-I labels, while canonical English labels and /// ASCII command IDs retain their old case-insensitive search in every interface language. #[test] @@ -3147,7 +3238,7 @@ mod tests { ("en", "INSERT.TABLE", "Table — Insert › Tables"), ] { h.state_mut().ui.language = language.into(); - h.state_mut().dialog = Some(Dialog::Commands { query: query.into() }); + h.state_mut().dialog = Some(Dialog::Commands { query: query.into(), swallow: false }); h.run_steps(4); assert!( h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains(expected)).is_some(), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ab36232..cccd22f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2462,3 +2462,5 @@ AutoFit Column by Double-Clicking Автопобиране на колона с двойно щракване Insert Caption… Вмъкване на надпис… Table Properties… Свойства на таблица… + Search Търсене + Title Bar Заглавна лента diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6a5b186..51b9962 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2078,3 +2078,5 @@ Correct accidental usage of cAPS LOCK key Opravovat náhodné použití klávesy cAPS LOCK Region: Oblast: Automatic ({region}) Automaticky ({region}) + Search Hledat + Title Bar Záhlaví okna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index de5099b..4d97ea4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2072,3 +2072,5 @@ Correct accidental usage of cAPS LOCK key Unbeabsichtigtes Verwenden der fESTSTELLTASTE korrigieren Region: Region: Automatic ({region}) Automatisch ({region}) + Search Suchen + Title Bar Titelleiste diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 03f185f..772227a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -167,6 +167,7 @@ Border Painter Pincel de bordes Border\nPainter Pincel de\nbordes Borders Bordes + Search Buscar Show the mini toolbar when text is selected Mostrar la minibarra de herramientas al seleccionar texto Spaces Espacios Spelling Ortografía @@ -183,6 +184,7 @@ Page Border Borde de página Apply to: Aplicar a: These marks show even when Show/Hide ¶ is off. Estas marcas se muestran aunque Mostrar u ocultar ¶ esté desactivado. + Title Bar Barra de título Typing replaces selected text Lo que escribes reemplaza el texto seleccionado Update fields before printing Actualizar campos antes de imprimir Use the Insert key to switch overtype on and off Usar la tecla Insert para activar y desactivar la sobrescritura diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 3674be9..dce73b8 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -664,6 +664,7 @@ Save as Template Salvesta mallina Save documents Dokumentide salvestamine Saved Salvestatud + Search Otsi Search Commands Otsi käske Search commands and help Otsi käske ja abi Search document Otsi dokumendist @@ -814,6 +815,7 @@ Tight Tihe TOC Heading Sisukorra pealkiri TOC {n} Sisukord {n} + Title Bar Tiitliriba Top Border Ülemine ääris Top of Page Lehe ülaserv Top: Ülal: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09567d2..17f5d47 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2081,3 +2081,5 @@ AutoFit Column by Double-Clicking Ajuster la colonne en double-cliquant Insert Caption… Insérer une légende… Table Properties… Propriétés du tableau… + Search Rechercher + Title Bar Barre de titre diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 15884a8..91429c6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2471,3 +2471,5 @@ AutoFit Column by Double-Clicking Oszlop automatikus igazítása dupla kattintással Insert Caption… Felirat beszúrása… Table Properties… Táblázat tulajdonságai… + Search Keresés + Title Bar Címsor diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9d7dc4e..cb3da2b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2457,3 +2457,5 @@ preview Title Esempio Correct accidental usage of cAPS LOCK key Correggi l'uso accidentale del tasto bLOC mAIUSC Region: Area geografica: Automatic ({region}) Automatico ({region}) + Search Cerca + Title Bar Barra del titolo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6e69b8d..ad54cb9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -155,6 +155,7 @@ Right (vertical) 右 (縦書き) Ruby text: ルビ文字: Save documents ドキュメントの保存 + Search 検索 Search for templates テンプレートを検索 Show all revisions inline すべての変更履歴を本文中に表示 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 表示 › モードの切り替えと同様に、文書の色を反転して表示します。保存、印刷、PDF は変わりません。 @@ -198,6 +199,7 @@ Page Border ページ罫線の設定 Apply to: 適用先: These marks show even when Show/Hide ¶ is off. これらの記号は、[編集記号の表示/非表示 ¶] がオフでも表示されます。 + Title Bar タイトル バー Typing replaces selected text 入力時に選択文字列を置き換える Update fields before printing 印刷前にフィールドを更新する Use the Insert key to switch overtype on and off Insert キーで上書きのオンとオフを切り替える diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f96f841..4847fe7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2114,3 +2114,5 @@ Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK Region: Region: Automatic ({region}) Automatycznie ({region}) + Search Wyszukaj + Title Bar Pasek tytułu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 30ce7db..c553d6b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -162,6 +162,7 @@ Border Painter Pincel de bordas Border\nPainter Pincel\nde bordas Borders Bordas + Search Pesquisar Show the mini toolbar when text is selected Mostrar a minibarra de ferramentas ao selecionar texto Spaces Espaços Spelling Ortografia @@ -178,6 +179,7 @@ Page Border Borda da página Apply to: Aplicar a: These marks show even when Show/Hide ¶ is off. Estas marcas aparecem mesmo com Mostrar/ocultar ¶ desativado. + Title Bar Barra de título Typing replaces selected text O que você digita substitui o texto selecionado Update fields before printing Atualizar campos antes de imprimir Use the Insert key to switch overtype on and off Usar a tecla Insert para ativar e desativar a sobrescrita diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6bcbd33..b038ff5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -189,6 +189,8 @@ Delete Style Удалить стиль Delete Table Удалить таблицу Design Дизайн + Search Поиск + Title Bar Строка заголовка development build сборка для разработки Diagonal Диагональ Diamond Ромб diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index fbeca08..242cfb0 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -204,6 +204,7 @@ Preview Pregled Remove Password Ukloni lozinku Save documents Čuvanje dokumenata + Search Pretraga Shadow, glow and soft edges Senka, sjaj i meke ivice Shape Effects Efekti oblika Show all revisions inline Prikaži sve izmene u tekstu @@ -255,6 +256,7 @@ Page Border Ivica strane Apply to: Primeni na: These marks show even when Show/Hide ¶ is off. Ove oznake se prikazuju i kada je Prikaži/sakrij ¶ isključeno. + Title Bar Naslovna traka Typing replaces selected text Otkucano zamenjuje izabrani tekst Update fields before printing Ažuriraj polja pre štampanja Use the Insert key to switch overtype on and off Taster Insert uključuje i isključuje prepisivanje diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index ad26145..0856528 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -208,6 +208,7 @@ Preview Преглед Remove Password Уклони лозинку Save documents Чување докумената + Search Претрага Shadow, glow and soft edges Сенка, сјај и меке ивице Shape Effects Ефекти облика Show all revisions inline Прикажи све измене у тексту @@ -259,6 +260,7 @@ Page Border Ивица стране Apply to: Примени на: These marks show even when Show/Hide ¶ is off. Ове ознаке се приказују и када је Прикажи/сакриј ¶ искључено. + Title Bar Насловна трака Typing replaces selected text Откуцано замењује изабрани текст Update fields before printing Ажурирај поља пре штампања Use the Insert key to switch overtype on and off Тастер Insert укључује и искључује преписивање diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1760a5e..636cbfe 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -622,6 +622,7 @@ Save As Farklı Kaydet Save as Template Şablon Olarak Kaydet Saved Kaydedildi + Search Ara Search Commands Komut Ara Search commands and help Komutlarda ve yardımda ara Search document Belgede ara @@ -769,6 +770,7 @@ preview Title Örnek TOA Heading Yetki Tablosu Başlığı TOC Heading İçindekiler Başlığı TOC {n} İçindekiler {n} + Title Bar Başlık Çubuğu Top Border Üst Kenarlık Top of Page Sayfa Üstü Top: Üst: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50baff9..03b8397 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -164,6 +164,7 @@ Border Painter Пензель меж Border\nPainter Пензель\nмеж Borders Межі + Search Пошук Show the mini toolbar when text is selected Показувати міні-панель інструментів під час виділення тексту Spaces Пробіли Spelling Правопис @@ -180,6 +181,7 @@ Page Border Межа сторінки Apply to: Застосувати до: These marks show even when Show/Hide ¶ is off. Ці знаки відображаються, навіть коли «Показати/приховати ¶» вимкнено. + Title Bar Рядок заголовка Typing replaces selected text Введення замінює виділений текст Update fields before printing Оновлювати поля перед друком Use the Insert key to switch overtype on and off Клавіша Insert вмикає та вимикає режим заміни diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0e7f35a..94b8846 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -167,6 +167,7 @@ Right (vertical) 右对齐(竖排) Ruby text: 拼音文字: Save documents 保存文档 + Search 搜索 Search for templates 搜索模板 Show all revisions inline 以嵌入方式显示所有修订 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像“视图 › 切换模式”一样以反转的颜色显示文档。保存、打印和 PDF 不受影响。 @@ -210,6 +211,7 @@ Page Border 页面边框线 Apply to: 应用于: These marks show even when Show/Hide ¶ is off. 即使关闭“显示/隐藏 ¶”,这些标记也会显示。 + Title Bar 标题栏 Typing replaces selected text 键入内容替换所选文字 Update fields before printing 打印前更新域 Use the Insert key to switch overtype on and off 用 Insert 键切换改写模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e322a0a..482e7c7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -156,6 +156,7 @@ Right (vertical) 靠右(直書) Ruby text: 注音文字: Save documents 儲存文件 + Search 搜尋 Search for templates 搜尋範本 Show all revisions inline 以內嵌方式顯示所有修訂 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像「檢視 › 切換模式」一樣以反轉的色彩顯示文件。儲存、列印和 PDF 不受影響。 @@ -199,6 +200,7 @@ Page Border 頁面框線設定 Apply to: 套用至: These marks show even when Show/Hide ¶ is off. 即使關閉「顯示/隱藏 ¶」,這些標記也會顯示。 + Title Bar 標題列 Typing replaces selected text 輸入的內容取代選取的文字 Update fields before printing 列印前更新功能變數 Use the Insert key to switch overtype on and off 使用 Insert 鍵切換取代模式 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index d0c670e..15ae9d1 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -376,6 +376,12 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } } +/// Whether a key runs its shortcut with the canvas unfocused (after a ribbon click, say): Mod and +/// Alt shortcuts (Alt+Q reaches Search from anywhere, as in Word, #819) and the function keys. +fn is_global_shortcut(key: Key, m: Modifiers) -> bool { + m.command || m.alt || is_function_key(key) +} + /// Command shortcuts when the canvas isn't focused but no text field is either. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. @@ -385,7 +391,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { if let egui::Event::Key { key, pressed: true, modifiers, .. } = e - && (modifiers.command || is_function_key(key) || ui_shortcut(key, modifiers).is_some()) + && is_global_shortcut(key, modifiers) { dispatch(app, key, modifiers); } @@ -438,4 +444,15 @@ mod tests { assert!(!is_context_menu_key(Key::F10, m(true, true, false)), "Ctrl+Shift+F10"); assert!(!is_context_menu_key(Key::F9, m(true, false, false)), "Shift+F9 is a field key"); } + + #[test] + fn alt_shortcuts_work_without_the_canvas_focused() { + let m = |command, alt, shift| egui::Modifiers { command, alt, shift, ..Default::default() }; + assert!(is_global_shortcut(Key::Q, m(false, true, false)), "Alt+Q (#819)"); + assert!(is_global_shortcut(Key::D, m(false, true, true)), "Alt+Shift+D"); + assert!(is_global_shortcut(Key::B, m(true, false, false)), "Mod+B"); + assert!(is_global_shortcut(Key::F7, m(false, false, false)), "F7"); + assert!(!is_global_shortcut(Key::Q, m(false, false, false)), "a plain letter is typing"); + assert!(!is_global_shortcut(Key::Q, m(false, false, true)), "Shift+letter is typing"); + } } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 7a33e62..19cafe8 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -81,6 +81,9 @@ const TAB_LETTERS: [(&str, &str); 11] = [ ("Help", "Y"), ]; +/// The Search box's badge beside the tab badges (Word's Alt, Q). +pub const SEARCH_LETTER: &str = "Q"; + /// The letter badge for a tab (empty when it has none). pub fn tab_letter(tab: &str) -> &'static str { TAB_LETTERS.iter().find(|(t, _)| *t == tab).map(|(_, l)| *l).unwrap_or("") @@ -543,6 +546,11 @@ pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool { // File opens the Backstage: no ribbon commands to badge. app.ui.keytips = if tab == "File" { Phase::Off } else { Phase::Commands }; } + // Q is the title bar's Search box, as in Word (#819). + None if name == SEARCH_LETTER => { + app.ui.keytips = Phase::Off; + let _ = app.run("tools.search", serde_json::json!({})); + } None => app.ui.keytips = Phase::Off, } } @@ -1118,4 +1126,17 @@ mod tests { b.apply_prefs(serde_json::from_str("{}").unwrap()); assert!(b.ui.keytips_enabled, "older preferences retain enabled KeyTips"); } + + /// Alt, then Q opens Search like Alt+Q (#819); no tab owns Q. + #[test] + fn alt_then_q_opens_search() { + let mut a = app(); + a.ui.backstage = false; + a.dialog = None; + assert!(TAB_LETTERS.iter().all(|(_, l)| *l != SEARCH_LETTER)); + activate(&mut a); + assert!(handle_key(&mut a, egui::Key::Q)); + assert_eq!(a.ui.keytips, Phase::Off); + assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::Commands { .. })), "{:?}", a.dialog.as_ref().map(|d| d.name())); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8edc874..990f75b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3117,10 +3117,11 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } - /// #819: Alt+Q moves to "Tell me" command search, as in Word for Windows, without typing a q. + /// #819: Alt+Q opens Search with its box focused, as in Word: the Q isn't typed into the + /// document, and what is typed next goes into the search box. #[cfg(not(target_os = "macos"))] #[test] - fn alt_q_opens_command_search_from_the_page() { + fn alt_q_opens_search_and_typing_goes_there() { 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(); @@ -3132,13 +3133,64 @@ mod tests { 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".into())); h.step(); - h.event(egui::Event::Key { key: egui::Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }); + // Some platforms send the key's text with an Alt shortcut; it isn't typed. + h.event(key(egui::Key::Q, egui::Modifiers::ALT)); h.event(egui::Event::Text("q".into())); h.step(); - assert!(matches!(h.state().dialog, Some(dialogs::Dialog::Commands { .. })), "command search is open"); + let query = |h: &egui_kittest::Harness| match &h.state().dialog { + Some(dialogs::Dialog::Commands { query, .. }) => Some(query.clone()), + _ => None, + }; + assert_eq!(query(&h).as_deref(), Some(""), "Search is open and empty"); + h.step(); + { + use egui_kittest::kittest::Queryable; + assert!(h.query_by_label_contains("Word Count").is_some(), "an empty box suggests commands, as Word's does"); + } + h.event(egui::Event::Text("bold".into())); + h.step(); + assert_eq!(query(&h).as_deref(), Some("bold"), "typing goes into the search box"); assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); + h.event(key(egui::Key::Escape, egui::Modifiers::NONE)); + h.step(); + h.step(); + assert!(h.state().dialog.is_none(), "Escape closes Search"); + // Words that name no command: Enter finds them in the document, in the Navigation pane. + h.event(key(egui::Key::Q, egui::Modifiers::ALT)); + h.step(); + h.step(); + // Typing sends each key's press before its text. + h.event(key(egui::Key::K, egui::Modifiers::SHIFT)); + h.event(egui::Event::Text("Kilns".into())); + h.step(); + h.event(key(egui::Key::Enter, egui::Modifiers::NONE)); + h.step(); + h.step(); + assert!(h.state().dialog.is_none(), "Enter ran the search"); + assert!(h.state().session.view.nav_pane, "the Navigation pane shows the results"); + assert_eq!(h.state().session.find.results.len(), 1); + } + + /// #819: `tools.search` with a query lists matching commands for scripts and agents; every + /// word has to match, so "insert table" finds the command that inserts a table. + #[test] + fn search_command_matches_every_word() { + let mut a = app(); + let r = a.run("tools.search", json!({"query": "insert table"})).unwrap(); + let ids: Vec<&str> = r["matches"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert!(ids.contains(&"insert.table"), "{ids:?}"); + assert!(r["matches"].as_array().unwrap().iter().all(|m| { + let hay = format!("{} {} {}", m["label"], m["location"], m["id"]).to_lowercase(); + hay.contains("insert") && hay.contains("table") + })); + assert_eq!(a.run("tools.search", json!({"query": " "})).unwrap()["matches"], json!([])); + assert_eq!(a.run("tools.search", json!({"query": "zzqqxx"})).unwrap()["matches"], json!([])); + assert!(a.dialog.is_none(), "a query never opens the box"); + a.run("tools.search", json!({})).unwrap(); + assert!(matches!(a.dialog, Some(dialogs::Dialog::Commands { .. })), "no query opens Search"); } /// Save writes the document first, then carries on with what the user asked for. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5d160a3..70fd819 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -8,6 +8,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- |---|---|---| | File | 22 | 22 | | Quick Access | 5 | 5 | +**484 of 499 catalog features have commands (97.0%).** + +| Tab | Live | Total | +|---|---|---| +| File | 21 | 21 | +| Quick Access | 4 | 4 | +| Title Bar | 1 | 1 | | Home | 102 | 103 | | Insert | 47 | 53 | | Draw | 10 | 11 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c41e475..cda21c1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -60,6 +60,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Ribbon buttons that run a command (alignment, Bold, styles…) hand keyboard focus back to the document, so typing continues without another click; they also report their name and enabled state to screen readers (#686) | | 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-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 |