diff --git a/ROADMAP.md b/ROADMAP.md index 5991bada7..d976d873c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | | 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f94b62d2d..f57c5a2d4 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -375,6 +375,7 @@ fn options_page(app: &mut WordApp, ui: &mut Ui) { } }); theme_picker(app, ui); + interface_size_picker(app, ui); // The browser can't write to the user's files, so AutoSave can't be turned on there (#176). let browser = app.autosave_block() == Some(crate::AutoSaveBlock::Browser); let mut autosave = app.autosave && !browser; @@ -424,6 +425,29 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) { }); } +/// File ▸ Options ▸ Interface size: 80–200 %, on top of the system's display scale (#475). +fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { + use crate::theme::InterfaceSize; + let current = app.ui.interface_size.percent(); + ui.horizontal(|ui| { + ui.label(tl!("Interface size:")); + egui::ComboBox::from_id_salt("interface_size").selected_text(format!("{current}%")).width(220.0).show_ui(ui, |ui| { + for pct in InterfaceSize::PRESETS { + if ui.selectable_label(current == pct, format!("{pct}%")).clicked() { + let _ = app.run("ui.interfaceSize", json!({"percent": pct})); + } + } + }); + }); + ui.label( + egui::RichText::new(tl!( + "Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate." + )) + .small() + .weak(), + ); +} + /// File ▸ Options ▸ Interface language: follow the system (the default) or pick one (#8). fn language_picker(app: &mut WordApp, ui: &mut Ui) { use crate::i18n::{AUTO, Lang}; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 80f0b3806..1fcc747b4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1327,3 +1327,5 @@ Flip Vertical Voltear verticalmente Flip Horizontal Voltear horizontalmente Formatted Con formato + Interface size: Tamaño de la interfaz: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index f35f90c0a..ff0dbe8cf 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1324,3 +1324,5 @@ Width and spacing Laius ja vahe from Unicode (hex) Unicode (kuueteistkümnend) Formatted Vormindatud + Interface size: Kasutajaliidese suurus: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3b477fe0..608254f74 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1311,3 +1311,5 @@ Flip Vertical 上下反転 Flip Horizontal 左右反転 Formatted 書式変更 + Interface size: 表示サイズ: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3186333c7..691a076dc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1323,3 +1323,5 @@ Flip Vertical Inverter verticalmente Flip Horizontal Inverter horizontalmente Formatted Formatado + Interface size: Tamanho da interface: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index f1eedfabd..77e96a03e 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1321,3 +1321,5 @@ Flip Vertical Okreni vertikalno Flip Horizontal Okreni horizontalno Formatted Formatirano + Interface size: Veličina interfejsa: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index cf8ddfaf6..33482a451 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1325,3 +1325,5 @@ Flip Vertical Окрени вертикално Flip Horizontal Окрени хоризонтално Formatted Форматирано + Interface size: Величина интерфејса: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a912425c6..a7de23fb4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1323,3 +1323,5 @@ Flip Vertical Віддзеркалити по вертикалі Flip Horizontal Віддзеркалити по горизонталі Formatted Відформатовано + Interface size: Розмір інтерфейсу: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 483ac472d..b330edf2d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1322,3 +1322,5 @@ Flip Vertical 垂直翻转 Flip Horizontal 水平翻转 Formatted 已设置格式 + Interface size: 界面大小: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bf1e0a2d3..18307a520 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1312,3 +1312,5 @@ Flip Vertical 垂直翻轉 Flip Horizontal 水平翻轉 Formatted 已設定格式 + Interface size: 介面大小: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 28e435911..2c039cb0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,9 @@ pub struct UiState { pub recent_symbols: Vec, /// Interface theme: light, dark, or follow the OS appearance (#115). pub theme: theme::Appearance, + /// Interface size in percent (#475): scales the whole interface on top of the system's + /// display scale. Junk or out-of-range saved values read as 100 % or are clamped to 80–200 %. + pub interface_size: theme::InterfaceSize, /// The dark-mode switch `ui.json` held before `theme` (#115): read once to migrate, never written. #[serde(rename = "dark", skip_serializing)] pub(crate) legacy_dark: Option, @@ -136,6 +139,7 @@ impl Default for UiState { recent: Vec::new(), recent_symbols: Vec::new(), theme: theme::Appearance::default(), + interface_size: theme::InterfaceSize::default(), legacy_dark: None, nav_tab: "headings".into(), show_discord: true, @@ -618,6 +622,23 @@ impl WordApp { } json!({"theme": self.ui.theme.code(), "dark": self.ui_is_dark()}) } + "ui.interfaceSize" => { + // Interface size in percent, 80–200 (clamped); no value reads it. Document zoom is + // `view.zoom` and stays as it is. + if let Some(v) = p.get("percent").or_else(|| p.get("value")) { + match v.as_f64().and_then(theme::InterfaceSize::from_percent) { + Some(size) => self.ui.interface_size = size, + None => { + return Some(Err(format!( + "`percent` must be a number from {} to {}", + theme::InterfaceSize::MIN, + theme::InterfaceSize::MAX + ))); + } + } + } + json!({"percent": self.ui.interface_size.percent()}) + } "ui.language" => { // `auto` (follow the system) or a language code; anything else is an error. if let Some(v) = s("value") { @@ -785,6 +806,13 @@ impl WordApp { theme::apply(ctx, self.ui.theme); self.applied_theme = Some(self.ui.theme); } + // Interface size (#475): egui's zoom factor multiplies the display scale the system reports + // (native pixels per point), so it adds to it rather than replacing it. Keyboard zoom is + // off (above), so nothing else changes the factor; `set_zoom_factor` repaints only on change. + let zoom = self.ui.interface_size.zoom_factor(); + if ctx.zoom_factor() != zoom { + ctx.set_zoom_factor(zoom); + } if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } @@ -2230,6 +2258,52 @@ mod tests { assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::Light, "Insert")); } + /// #475: the interface size is kept between runs; junk saved values read as 100 % and + /// out-of-range ones are clamped, without losing the other preferences. + #[test] + fn interface_size_survives_a_restart_and_clamps_junk() { + let mut a = app(); + assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap()["percent"], 100); + assert_eq!(a.run("ui.interfaceSize", json!({"percent": 150})).unwrap()["percent"], 150); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""interfaceSize":150"#), "{saved}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.interface_size.percent(), 150); + for (raw, want) in [("1000", 200), ("10", 80), ("-5", 80), ("124.6", 125), (r#""big""#, 100), ("null", 100), ("[1]", 100)] { + let ui: UiState = serde_json::from_str(&format!(r#"{{"interfaceSize": {raw}, "tab": "Insert"}}"#)).unwrap(); + assert_eq!((ui.interface_size.percent(), ui.tab.as_str()), (want, "Insert"), "{raw}"); + } + assert_eq!(a.run("ui.interfaceSize", json!({"percent": 500})).unwrap()["percent"], 200); + assert!(a.run("ui.interfaceSize", json!({"percent": "huge"})).is_err()); + assert!(a.run("ui.interfaceSize", json!({"percent": null})).is_err()); + assert_eq!(a.ui.interface_size.percent(), 200, "a rejected value keeps the setting"); + } + + /// #475: the interface size multiplies the display scale the system reports (egui's zoom + /// factor) and leaves document zoom alone. + #[test] + fn interface_size_scales_on_top_of_the_system_scale() { + let ctx = egui::Context::default(); + let mut a = app(); + let doc_zoom = a.session.view.zoom; + a.run("ui.interfaceSize", json!({"percent": 150})).unwrap(); + let frame = |a: &mut WordApp, native: f32| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); + ctx.run_ui(input, |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + }; + // The new factor applies from the next pass. + frame(&mut a, 2.0); + frame(&mut a, 2.0); + assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (1.5, 3.0)); + a.run("ui.interfaceSize", json!({"percent": 80})).unwrap(); + frame(&mut a, 1.0); + frame(&mut a, 1.0); + assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (0.8, 0.8)); + assert_eq!(a.session.view.zoom, doc_zoom, "document zoom is a separate setting"); + } + /// #115: System follows the OS appearance (light when unknown); the manual choices ignore it. #[test] fn interface_theme_resolves_against_the_os_appearance() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 8924a1bdf..997c2d1cd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -79,6 +79,55 @@ impl From for String { } } +/// Interface size (File › Options › General, `ui.interfaceSize`), in percent (#475): scales the +/// ribbon, menus, panes and dialogs on top of the display scale the system reports, through +/// egui's zoom factor. Document zoom (View › Zoom) is a separate setting and never changes with it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +#[serde(from = "serde_json::Value", into = "u16")] +pub struct InterfaceSize(u16); + +impl InterfaceSize { + pub const MIN: u16 = 80; + pub const MAX: u16 = 200; + pub const DEFAULT: InterfaceSize = InterfaceSize(100); + /// The choices in File › Options, in percent. + pub const PRESETS: [u16; 8] = [80, 90, 100, 110, 125, 150, 175, 200]; + + /// A size from any number: rounded and clamped to 80–200 %; `None` for NaN or infinity. + pub fn from_percent(percent: f64) -> Option { + percent.is_finite().then(|| InterfaceSize(percent.round().clamp(f64::from(Self::MIN), f64::from(Self::MAX)) as u16)) + } + + pub fn percent(self) -> u16 { + self.0 + } + + /// The egui zoom factor (1.0 at 100 %). + pub fn zoom_factor(self) -> f32 { + f32::from(self.0) / 100.0 + } +} + +impl Default for InterfaceSize { + fn default() -> Self { + Self::DEFAULT + } +} + +/// A saved value that isn't a number reads as 100 %, and an out-of-range one is clamped, rather than +/// discarding the whole `ui.json`. +impl From for InterfaceSize { + fn from(v: serde_json::Value) -> Self { + v.as_f64().and_then(InterfaceSize::from_percent).unwrap_or_default() + } +} + +impl From for u16 { + fn from(s: InterfaceSize) -> Self { + s.0 + } +} + /// Every colour the UI uses; widgets never hard-code colours. #[derive(Clone, Copy, Debug)] pub struct Tokens { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 848a8179e..7e74c791a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,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"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none)…) | +| `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"}`), `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`)…) | | `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 d5ec4dcb0..8270e6c21 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -30,6 +30,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | +| Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on, document canvas exposure untested | full | 25% | 10–15 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | @@ -38,6 +39,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | | 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) | | 2026-10-10 | trivial | Columns (presets, unequal widths, line between, apply to), Symbol (font coverage grid by Unicode subset, recently used, character code, Special Characters) and Field (categories, options, field codes) dialogs (#321) |