From 31cb2c8714e83a5e799348d4ca25b2db2be4446a Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:19 +0200 Subject: [PATCH] #1861: searchable Interface Theme command (View, light/dark/system + toggle) (#1868) Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/view.rs | 20 ++++++++++++++++++++ crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 33 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 2 +- docs/ui-parity.md | 4 ++-- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d79d0a905..ff2989913 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -70,6 +70,26 @@ pub fn specs() -> Vec { CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(), CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(), CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(), + // The interface theme itself (light, dark, or follow the OS), next to dark pages (#1861). + // The session can't paint: like split and new windows, this queues a request the front + // end applies to its theme setting (which persists per user). Without a value it toggles + // light and dark, as the manual switch did. + CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") { + None => { + s.ui_requests.push(json!({"toggle": "interfaceTheme"})); + Ok(json!({"value": "toggle"})) + } + Some(Value::String(name)) => match name.as_str() { + "light" | "dark" | "system" => { + s.ui_requests.push(json!({"interfaceTheme": name})); + Ok(json!({"value": name})) + } + _ => Err(CmdError::Params("interface theme: light|dark|system".into())), + }, + _ => Err(CmdError::Params("interface theme: light|dark|system".into())), + }) + .params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#) + .pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a5b4f8b3a..72aaa9cee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4290,6 +4290,26 @@ fn navigation_pane_opens_without_changing_view_mode() { } } +#[test] +fn interface_theme_queues_a_front_end_request_and_rejects_junk() { + // #1861: the session can't paint, so the command asks the front end; the document, + // view state and undo stack are untouched. + let mut s = s(); + let dirty = s.dirty; + for value in ["light", "dark", "system"] { + let r = run(&mut s, "view.interfaceTheme", json!({"value": value})); + assert_eq!(r["value"], value); + assert_eq!(s.ui_requests.last().unwrap()["interfaceTheme"], value); + } + let r = run(&mut s, "view.interfaceTheme", json!({})); + assert_eq!(r["value"], "toggle"); + assert_eq!(s.ui_requests.last().unwrap()["toggle"], "interfaceTheme"); + assert_eq!(s.dirty, dirty); + for bad in [json!({"value": "midnight"}), json!({"value": 3})] { + assert!(s.run("view.interfaceTheme", &bad).is_err(), "{bad}"); + } +} + #[test] fn reading_mode_reflows_per_viewport_without_changing_document_or_export() { let mut s = s(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 42eafa268..052b567fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1299,6 +1299,15 @@ impl WordApp { self.nav_search_focus = true; self.ui.nav_tab = "results".into(); } + // #1861: the registered Interface Theme command drives the same setting as Options. + if let Some(name) = req.get("interfaceTheme").and_then(Value::as_str) + && let Some(a) = theme::Appearance::from_code(name) + { + self.ui.theme = a; + } + if req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme") { + self.ui.theme = if self.ui_is_dark() { theme::Appearance::Light } else { theme::Appearance::Dark }; + } if let Some(what) = req.get("open").and_then(Value::as_str) { match what { "openFile" => self.open_dialog(), @@ -6119,6 +6128,30 @@ mod tests { assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::System, "Insert")); } + /// #1861: the registered Interface Theme command drives the same setting as Options: + /// searchable in the registry, settable, toggling without a value, and surviving a restart. + #[test] + fn interface_theme_command_sets_toggles_and_persists() { + use theme::Appearance; + let mut a = app(); + let spec = a.session.registry.get("view.interfaceTheme").expect("registered and searchable"); + assert_eq!((spec.label, spec.location), ("Interface Theme", "View › Dark Mode")); + a.run("view.interfaceTheme", json!({"value": "dark"})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Dark); + a.run("view.interfaceTheme", json!({"value": "system"})).unwrap(); + assert_eq!(a.ui.theme, Appearance::System); + a.run("view.interfaceTheme", json!({"value": "light"})).unwrap(); + a.run("view.interfaceTheme", json!({})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Dark, "no value toggles light to dark"); + a.run("view.interfaceTheme", json!({})).unwrap(); + assert_eq!(a.ui.theme, Appearance::Light, "and back"); + assert!(a.run("view.interfaceTheme", json!({"value": "sepia"})).is_err()); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.theme, Appearance::Light); + } + /// #475, #822: the interface size is kept between runs; junk saved values read as Auto and /// out-of-range ones are clamped, without losing the other preferences. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cd7083309..6b4afd9c4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2108,7 +2108,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| { for a in crate::theme::Appearance::ALL { if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { - let _ = app.run("ui.theme", json!({"value": a.code()})); + let _ = app.run("view.interfaceTheme", json!({"value": a.code()})); ui.close(); } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3486a8e10..c6229559c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -46,8 +46,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252) | ✅ | ✅ | 90% | — | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | +| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), 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 browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | | Full screen (#980): View › Window › Full Screen (`view.fullScreen`), F11 (⌃⌘F on macOS) toggles OS-level full screen, Escape leaves it, leaving gives back the earlier window size; the window manager's or macOS's own full screen keeps it in step. In the browser it's off (the browser's own F11) | ✅ | Word for Mac: View › Enter Full Screen (⌃⌘F); Word for Windows: Full Screen Mode hides the ribbon only | 100% | — | @@ -219,3 +218,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 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-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) | +| 2026-10-15 | trivial | Interface Theme as a searchable View › Dark Mode command (light/dark/system, toggles light and dark without a value), driving the same setting as Options; ribbon menu rewired to it (#1861) |