Shift+F10 opens the page's context menu at the caret (#821) (#920)

The right-click menu could only be opened with the mouse. Shift+F10,
Windows' keyboard way to the context menu (the Menu key's equivalent),
now opens the same menu just below the caret, with the spelling
suggestions and synonyms for the word there; Escape closes it.
ui.contextMenu opens it for scripts and agents.

The Menu key itself still does nothing: egui-winit drops that key
before the app sees it, so it needs an upstream change (or a winit
hook) to reach the same path.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 10:28:22 -04:00
1 parent d863811511
commit 994162fdff
6 files changed
+110 -6

No files matched your search

+1
View File
@@ -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) |
|---|---|
+38 -6
View File
@@ -74,6 +74,10 @@ pub struct CanvasState {
pub context_synonyms: Option<serde_json::Value>,
/// 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<Pos2>,
/// Screen rect of the selection's first line, for the floating mini toolbar.
pub mini_anchor: Option<Rect>,
/// 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();
+20
View File
@@ -150,6 +150,12 @@ fn shift_variant_params(id: &str) -> Option<Value> {
}
}
/// 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");
}
}
+49
View File
@@ -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<WordApp>| 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");
}
}
+1
View File
@@ -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 |
+1
View File
@@ -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 |