diff --git a/ROADMAP.md b/ROADMAP.md index d60e430..3e39592 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,6 +161,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode | | 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-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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d94995d..4c386dc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -74,6 +74,10 @@ pub struct CanvasState { pub context_synonyms: Option, /// The right-click context menu is open (the mini toolbar stands down while it is). pub context_menu_open: bool, + /// Shift+F10 (or `ui.contextMenu`) asked for the context menu at the caret (#821). + pub context_menu_from_keys: bool, + /// Where the context menu stands: the right-click point, or below the caret. + pub context_menu_at: Option, /// Screen rect of the selection's first line, for the floating mini toolbar. pub mini_anchor: Option, /// A picture, shape or text box being dragged by its frame. @@ -168,6 +172,8 @@ impl Default for CanvasState { erasing: None, ink_ui: Default::default(), context_menu_open: false, + context_menu_from_keys: false, + context_menu_at: None, mini_anchor: None, pasted: false, swallow_text: false, @@ -1200,14 +1206,33 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() { app.session.sel = wordcraft_engine::Selection::caret(pos); } - app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null()); - app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned()); + context_suggestions(app); } } - resp.context_menu(|ui| context_menu(app, ui)); - // Stand down while the context menu owns the pointer so the two never double up; - // `context_menu_opened` also covers the click that dismisses the menu. - app.canvas.context_menu_open = resp.context_menu_opened(); + // Shift+F10 opens the same menu below the caret, for the selection or the word there (#821). + let from_keys = std::mem::take(&mut app.canvas.context_menu_from_keys); + if from_keys { + context_suggestions(app); + app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h)); + } else if resp.secondary_clicked() { + app.canvas.context_menu_at = resp.interact_pointer_pos(); + } + let open = if resp.secondary_clicked() || from_keys { + Some(egui::SetOpenCommand::Bool(true)) + } else if resp.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + let menu = egui::Popup::menu(&resp).open_memory(open); + let menu = match app.canvas.context_menu_at { + Some(p) => menu.at_position(p), + None => menu.at_pointer_fixed(), + }; + menu.show(|ui| context_menu(app, ui)); + // Stand down while the context menu owns the pointer so the two never double up; the open + // state also covers the click that dismisses the menu. + app.canvas.context_menu_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)); if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) { ui.ctx().set_cursor_icon(c); } else if resp.hovered() || app.canvas.obj_drag.is_some() { @@ -2203,6 +2228,13 @@ fn dims_body(app: &WordApp, layout: &DocLayout) -> bool { } } +/// The spelling suggestions and synonyms the context menu offers for the selection or the word +/// at the caret. +fn context_suggestions(app: &mut WordApp) { + app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null()); + app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned()); +} + /// Caret position on screen. pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> { let l = app.session.layout(); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 914e0c8..d0c670e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -150,6 +150,12 @@ fn shift_variant_params(id: &str) -> Option { } } +/// Shift+F10 opens the context menu, as the Menu key does (#821). (The Menu key itself doesn't +/// reach the app: the windowing layer drops it.) +fn is_context_menu_key(key: Key, m: Modifiers) -> bool { + key == Key::F10 && m.shift && !m.command && !m.alt && !m.ctrl +} + /// F1–F12: shortcuts on these work without the canvas focused. fn is_function_key(key: Key) -> bool { matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12) @@ -344,6 +350,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { if crate::objects::key(app, key, modifiers) { continue; } + // Shift+F10, Windows' keyboard way to the context menu (#821). + if is_context_menu_key(key, modifiers) { + app.canvas.context_menu_from_keys = true; + continue; + } // Escape leaves a text box to just after it in the body. if key == Key::Escape && crate::canvas::in_text_box(app) @@ -418,4 +429,13 @@ mod tests { assert!(is_paste_special_release(Key::V, m(true, false, true)), "Mod+Alt+V opens Paste Special"); assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false))); } + + #[test] + fn shift_f10_is_the_context_menu_key() { + let m = |shift, ctrl, alt| egui::Modifiers { shift, ctrl, command: ctrl, alt, ..Default::default() }; + assert!(is_context_menu_key(Key::F10, m(true, false, false))); + assert!(!is_context_menu_key(Key::F10, m(false, false, false)), "F10 alone shows keytips"); + 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"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 56a2f4b..3be34ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -976,6 +976,12 @@ impl WordApp { self.pick_picture(); json!({"pending": self.change_picture_target.is_some()}) } + // The page's context menu at the caret, as Shift+F10 or the Menu key opens it (#821). + "ui.contextMenu" => { + self.canvas.context_menu_from_keys = true; + self.canvas.want_focus = true; + json!({"pending": "contextMenu"}) + } "ui.collapseRibbon" => { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; json!({"collapsed": self.ui.ribbon_collapsed}) @@ -4076,4 +4082,47 @@ mod tests { assert_eq!(v["result"]["width"], 2, "{v}"); assert_eq!(v["result"]["height"], 1, "{v}"); } + + /// #821: Shift+F10 opens the page's context menu below the caret, like right-clicking there + /// (Word's keyboard way to it; the Menu key isn't passed on by the windowing layer); Escape + /// closes it and typing goes on. + #[test] + fn shift_f10_opens_the_context_menu_at_the_caret() { + use egui_kittest::kittest::Queryable; + 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".into())); + h.step(); + let pastes = |h: &egui_kittest::Harness| h.query_all_by_label_contains("Paste").count(); + let before = pastes(&h); + h.event(key(egui::Key::F10, egui::Modifiers::SHIFT)); + h.step(); + h.step(); + assert!(h.state().canvas.context_menu_open, "Shift+F10 opened the menu"); + assert!(pastes(&h) > before, "the page's context menu, with Paste"); + let caret = canvas::caret_screen(h.state_mut()).unwrap(); + let at = h.state().canvas.context_menu_at.unwrap(); + assert!((at.x - caret.0.x).abs() < 1.0 && at.y > caret.0.y, "below the caret: {at:?} vs {caret:?}"); + assert_eq!(h.state().ui.keytips, keytips::Phase::Off, "Shift+F10 doesn't show keytips"); + h.event(key(egui::Key::Escape, egui::Modifiers::NONE)); + h.step(); + h.step(); + assert!(!h.state().canvas.context_menu_open, "Escape closes it"); + // Scripts and agents open it too. + h.state_mut().run("ui.contextMenu", json!({})).unwrap(); + h.step(); + h.step(); + assert!(h.state().canvas.context_menu_open); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 478577a..02c0eb9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,6 +51,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7fe844..460956a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -56,6 +56,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Quick Access Toolbar KeyTips (#620): 1–9, zero-prefixed numbers for longer toolbars, disabled commands stay disabled, Escape cancels, badge keystrokes never type into the document; persisted File › Options switch | | 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-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 |