diff --git a/ROADMAP.md b/ROADMAP.md index 3922a7e..9f78f93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | TRIMRANGE: ranges and arrays without blank outer rows and columns (#296) | | 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) | | 2026-10-11 | Ribbon translated into German (266 labels; `de`, `de-DE`, `de-AT`, `de-CH` locale tags resolve to German) | +| 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fa46f2f..b95d897 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -173,6 +173,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), + ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4b13530..ef2f65e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -353,6 +353,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Light", "ライト"), ("Dark", "ダーク"), ("Interface language", "表示言語"), + ("Interface size", "表示サイズ"), + ("Auto", "自動"), ("Command\nPalette", "コマンド パレット"), ("Agent\nControl", "エージェント制御"), ("Action\nJournal", "操作ジャーナル"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..d19571e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "틀 고정 취소", "Dark Mode" => "다크 모드", "Interface language" => "인터페이스 언어", + "Interface size" => "인터페이스 크기", + "Auto" => "자동", "Command\nPalette" => "명령\n팔레트", "Agent\nControl" => "에이전트\n제어", "Action\nJournal" => "작업\n기록", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..f402c1c 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -193,6 +193,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Descongelar Painéis", "Dark Mode" => "Modo Escuro", "Interface language" => "Idioma da interface", + "Interface size" => "Tamanho da interface", + "Auto" => "Automático", "Command\nPalette" => "Paleta de\nComandos", "Agent\nControl" => "Controle\ndo Agente", "Action\nJournal" => "Registro\nde Ações", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..99b40bc 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Снять закрепление областей", "Dark Mode" => "Тёмный режим", "Interface language" => "Язык интерфейса", + "Interface size" => "Размер интерфейса", + "Auto" => "Авто", "Command\nPalette" => "Палитра\nкоманд", "Agent\nControl" => "Управление\nагентом", "Action\nJournal" => "Журнал\nдействий", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..181cfa4 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "取消冻结窗格", "Dark Mode" => "深色模式", "Interface language" => "界面语言", + "Interface size" => "界面大小", + "Auto" => "自动", "Command\nPalette" => "命令\n面板", "Agent\nControl" => "智能体\n控制", "Action\nJournal" => "操作\n日志", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9808b20..c99ffe3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,25 @@ pub struct UiState { /// rather than failing the whole `UiState`. #[serde(deserialize_with = "i18n::lenient")] pub language: i18n::Language, + /// Interface size in percent on top of the desktop's scale (100 = Auto), applied as egui's + /// zoom factor so the ribbon, menus, dialogs and grid grow together. Kept within + /// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100. + #[serde(deserialize_with = "lenient_interface_scale")] + pub interface_scale: u16, +} + +pub const INTERFACE_SCALE_MIN: u16 = 75; +pub const INTERFACE_SCALE_MAX: u16 = 200; + +/// A requested interface size, rounded and clamped to the supported range; `None` when it isn't +/// a finite number. +pub fn clamp_interface_scale(percent: f64) -> Option { + percent.is_finite().then(|| percent.round().clamp(INTERFACE_SCALE_MIN as f64, INTERFACE_SCALE_MAX as f64) as u16) +} + +fn lenient_interface_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = Json::deserialize(d)?; + Ok(v.as_f64().and_then(clamp_interface_scale).unwrap_or(100)) } impl Default for UiState { @@ -69,6 +88,7 @@ impl Default for UiState { status_bar: true, recent: vec![], language: i18n::Language::En, + interface_scale: 100, } } } @@ -84,6 +104,11 @@ impl UiState { } } + /// The zoom factor for the interface size preference. + pub fn zoom_factor(&self) -> f32 { + clamp_interface_scale(self.interface_scale as f64).unwrap_or(100) as f32 / 100.0 + } + pub fn theme_mode(&self) -> &'static str { if self.system_theme { "system" @@ -334,6 +359,21 @@ impl SheetApp { } Ok(json!({"mode": self.ui.theme_mode()})) } + "view.interfaceScale" => { + // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. + let percent = match p.get("percent") { + Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), + Some(v) => v.as_f64().and_then(clamp_interface_scale), + None => None, + }; + match percent { + Some(v) => { + self.ui.interface_scale = v; + Ok(json!({"percent": v})) + } + None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), + } + } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), "app.language.set" => { // `{"language": "ja"}`; `code` is accepted as an alias. @@ -582,6 +622,16 @@ impl SheetApp { ctx.request_repaint(); } self.effective_dark = ctx.theme() == egui::Theme::Dark; + // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom + // keys stay off (they would also swallow GridCraft's shortcuts). + if ctx.options(|o| o.zoom_with_keyboard) { + ctx.options_mut(|o| o.zoom_with_keyboard = false); + } + let zoom = self.ui.zoom_factor(); + if (ctx.zoom_factor() - zoom).abs() > 0.001 { + ctx.set_zoom_factor(zoom); + ctx.request_repaint(); + } control::poll(self, ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 98318b7..d9fcc52 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1697,6 +1697,22 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { }); }); sep(ui); + // Interface size (issue #230): scales the whole window on top of the desktop's scale. + ui.vertical(|ui| { + ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); + let current = app.ui.interface_scale; + let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; + egui::ComboBox::from_id_salt("ui_interface_size") + .width(120.0) + .selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { + for v in [100, 75, 90, 110, 125, 150, 175, 200] { + if ui.selectable_label(v == current, text(v)).clicked() { + act(app, "view.interfaceScale", json!({"percent": v})); + } + } + }); + }); // Interface language: settings live in the View tab, like Excel's Options. The label follows // the current language so it stays readable; the choices are always shown in their own script. ui.vertical(|ui| { diff --git a/crates/ui-egui/tests/interface_scale.rs b/crates/ui-egui/tests/interface_scale.rs new file mode 100644 index 0000000..b885bf8 --- /dev/null +++ b/crates/ui-egui/tests/interface_scale.rs @@ -0,0 +1,42 @@ +//! Interface size preference (#230): a persistent zoom for the whole window, clamped. + +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, UiState}; +use serde_json::json; + +#[test] +fn interface_size_applies_persists_and_clamps() { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(2); + assert_eq!(h.ctx.zoom_factor(), 1.0); + + h.state_mut().run("view.interfaceScale", json!({"percent": 125})).unwrap(); + h.run_steps(2); + assert_eq!(h.ctx.zoom_factor(), 1.25); + let saved: UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); + assert_eq!(saved.interface_scale, 125); + + // Hostile and missing values: clamped or refused, never applied raw. + assert_eq!(h.state_mut().run("view.interfaceScale", json!({"percent": 1e12})).unwrap()["percent"], 200); + assert_eq!(h.state_mut().run("view.interfaceScale", json!({"percent": -5})).unwrap()["percent"], 75); + assert!(h.state_mut().run("view.interfaceScale", json!({})).is_err(), "no value is an error, not a dialog"); + assert!(h.state_mut().run("view.interfaceScale", json!({"percent": "huge"})).is_err()); + h.state_mut().run("view.interfaceScale", json!({"percent": "auto"})).unwrap(); + h.run_steps(2); + assert_eq!(h.ctx.zoom_factor(), 1.0); + + // A malformed or out-of-range ui.json keeps the other preferences. + let ui: UiState = serde_json::from_str(r#"{"dark": true, "interfaceScale": "big"}"#).unwrap(); + assert!(ui.dark); + assert_eq!(ui.interface_scale, 100); + let ui: UiState = serde_json::from_str(r#"{"interfaceScale": 900}"#).unwrap(); + assert_eq!(ui.interface_scale, 200); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 083977d..974a673 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -12,7 +12,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | -| High-DPI and display scaling | yes | yes (display scale matched to desktop density) | — | +| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop prints through the system dialog via PDF; web exports only | Counted under print |