From 6a1268ea9c2fba963aba07e943ab23385aaa2f19 Mon Sep 17 00:00:00 2001 From: SlavenDj Date: Wed, 14 Oct 2026 23:34:57 +0200 Subject: [PATCH] #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers --- crates/ui-egui/src/dialogs_keyboard.rs | 71 +++++++++++++++++++++++++- crates/ui-egui/src/dialogs_ribbon.rs | 49 ++++++++++++++++-- docs/ui-parity.md | 5 +- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 1c5638594..d5fa6e20a 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -23,6 +23,9 @@ pub struct KeyboardForm { pub category: String, /// The selected command id. pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The key picked in "Keys for this command" (for Remove). pub current: String, /// The key combination pressed in "New shortcut", canonical (`Mod+Shift+K`). @@ -72,6 +75,32 @@ pub(crate) fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, Stri out } +/// The commands to list for `category`, narrowed by `query` (#1845): an empty query keeps the +/// category scope; any other text searches every registered command by its id and its English or +/// translated label, case-insensitive (the id lets "file.save" or "save" find Save). Sorted as +/// [`commands`]. +pub(crate) fn matching_commands(app: &WordApp, category: &str, query: &str) -> Vec<(String, String)> { + let q = crate::i18n::lowercase(query.trim()); + if q.is_empty() { + return commands(app, category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + } + let mut out: Vec<(String, String)> = app + .session + .registry + .all() + .iter() + .filter(|s| s.id.to_lowercase().contains(&q) || s.label.to_lowercase().contains(&q) || crate::i18n::lowercase(tl!(s.label)).contains(&q)) + .map(|s| (s.id.to_string(), tl!(s.label).to_string())) + .collect(); + out.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(&b.0))); + out +} + +/// Keep `wanted` when it is still listed, else fall back to the first row (#1845). +pub(crate) fn select_in(rows: &[(String, String)], wanted: &str) -> String { + if rows.iter().any(|(id, _)| id == wanted) { wanted.to_string() } else { rows.first().map(|(id, _)| id.clone()).unwrap_or_default() } +} + /// Only combinations with Mod, Ctrl or Alt, or function keys, can be assigned from the dialog /// (plain letters and Shift+letter type text). fn assignable(key: &str) -> bool { @@ -141,15 +170,21 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) let (left, right) = (170.0, 300.0); heading(ui, "Choose a command"); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((ALL.to_string(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(tl!("Categories:")); if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); f.current.clear(); } }); @@ -292,4 +327,36 @@ mod tests { assert_eq!(c.ui.tab, "Insert"); assert!(c.session.keymap.is_empty()); } + + /// #1845: the command filter searches every command by label or id, ignoring the category. + #[test] + fn command_filter_searches_all_commands() { + use super::{matching_commands, select_in}; + + let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + let a = app(); + // Empty query keeps the category scope. + let file_cmds = matching_commands(&a, "File", ""); + assert!(!file_cmds.is_empty()); + assert!( + file_cmds + .iter() + .all(|(id, _)| crate::i18n::location(a.session.registry.get(id).map(|s| s.location).unwrap_or_default()).starts_with("File")) + ); + assert!(file_cmds.iter().any(|(id, _)| id == "file.save")); + // A label finds its command from another category's scope. + let bold = matching_commands(&a, "File", "bold"); + assert!(bold.iter().any(|(id, _)| id == "format.bold")); + // Matching is case-insensitive and covers ids. + assert!(matching_commands(&a, "File", "SAVE").iter().any(|(id, _)| id == "file.save")); + assert!(matching_commands(&a, "File", "format.b").iter().any(|(id, _)| id == "format.bold")); + // Nothing matches: empty rows and an empty selection. + let none = matching_commands(&a, "File", "zzz-no-such-command"); + assert!(none.is_empty()); + assert_eq!(select_in(&none, "file.save"), ""); + // The selection survives when still listed, else falls back to the first row. + assert_eq!(select_in(&bold, "format.bold"), "format.bold"); + let first = bold.first().map(|(id, _)| id.clone()).unwrap_or_default(); + assert_eq!(select_in(&bold, "file.save"), first); + } } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index cb73e3718..378cb4cd3 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::WordApp; use crate::dialogs::Dialog; -use crate::dialogs_keyboard::{categories, commands, list}; +use crate::dialogs_keyboard::{categories, commands, list, matching_commands, select_in}; use crate::theme::semibold; /// The dialog's two pages. @@ -39,6 +39,9 @@ pub struct RibbonForm { /// The left list: a category (empty for All Commands) and the command picked in it. pub category: String, pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The item picked in the ribbon tree. pub selected: TreeItem, /// The command picked in the Quick Access Toolbar list. @@ -97,16 +100,22 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b }); ui.separator(); let (left, right, h) = (150.0, 230.0, 300.0); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((String::new(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5))); ui.horizontal_top(|ui| { if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); } if let Some(c) = list(ui, "rb_commands", vec2(right, h), &cmds, &f.command) { f.command = c; @@ -456,6 +465,40 @@ mod tests { assert_eq!(a.ui.tab, "Insert", "a hidden tab isn't shown"); } + /// #1845: the shared command picker (Ribbon and Quick Access Toolbar pages) filters + /// as you type, and Add works from a filtered selection. + #[test] + fn command_filter_narrows_the_picker_and_add_works() { + use crate::dialogs_keyboard::{matching_commands, select_in}; + + let mut a = app(); + a.run("tools.customizeRibbon", json!({})).unwrap(); + assert!(matches!(a.dialog, Some(Dialog::CustomizeRibbon { .. }))); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.filter = "bold".to_string(); + } + let (category, filter, current) = match &a.dialog { + Some(Dialog::CustomizeRibbon { form }) => (form.category.clone(), form.filter.clone(), form.command.clone()), + _ => panic!("expected the Customize Ribbon dialog"), + }; + // Empty filter keeps the category scope. + let unfiltered = matching_commands(&a, &category, ""); + assert!(!unfiltered.is_empty()); + // Typing searches every command, ignoring the category. + let cmds = matching_commands(&a, &category, &filter); + assert!(cmds.iter().any(|(id, _)| id == "format.bold")); + assert!(cmds.len() < a.session.registry.all().len()); + // The dialog's Add >> adds the filtered pick to the Quick Access Toolbar. + let picked = select_in(&cmds, ¤t); + assert!(!picked.is_empty()); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.command = picked.clone(); + } + a.run("tools.customizeRibbon", json!({"qatAdd": picked})).unwrap(); + assert!(a.session.ribbon.qat().contains(&picked.as_str())); + assert!(a.session.ribbon.qat().contains(&"format.bold")); + } + /// #376: the Quick Access Toolbar's … button opens its menu, and the toolbar shows the user's /// commands. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b14de544..ff9f27248 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -18,7 +18,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | -| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file; filter-as-you-type command search in all three pickers (#1845) | ✅ | 85% | 1–2 | | Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | @@ -160,3 +160,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | | 2026-10-12 | trivial | Recipient List dialog: Filter and Sort section (conditions joined by And/Or, three sort keys, merge order column), .xls in the picker (#778) | | 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section | +| 2026-10-14 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) |