From 34aa98870d2e388c353a1aa718324609a549c54b Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 14 Oct 2026 20:20:10 +1100 Subject: [PATCH] Zoom dialog: Excel's layout, current zoom pre-selected, Fit selection (#930) View > Zoom is now one Magnification column as in Excel: 200%, 100%, 75%, 50%, 25%, Fit selection and Custom with its percentage box. The current zoom is pre-selected (Custom when it matches no preset), the box shows whole percentages clamped to 10..400 and typing in it selects Custom. Fit selection runs view.zoomToSelection with the grid's size, the same answer as the ribbon's Zoom to Selection. The window is 220 px wide instead of 380; its height is left to the shared dialog sizing work (#978). Magnification and Fit selection are translated in every catalog. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr --- crates/ui-egui/src/dialogs.rs | 100 ++++++++++++++++++++-------- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/sr.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/tests/zoom_dialog.rs | 62 +++++++++++++++++ 12 files changed, 154 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..b7b9488c4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,13 +47,28 @@ impl Dialog { &self.name } - /// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores. + /// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` + /// stores, a typed percentage selects Custom, and Fit selection makes OK run + /// `view.zoomToSelection` (its view size was captured when the dialog opened). pub(crate) fn set_value(&mut self, key: &str, value: Json) { - if self.name == "zoom" - && key == "preset" - && let Some(p) = value.as_str().and_then(|s| s.parse::().ok()) - { - self.values.insert("percent".into(), json!(p)); + if self.name == "zoom" { + match key { + "preset" => match value.as_str() { + Some("fit") => self.command = Some("view.zoomToSelection"), + Some(s) => { + self.command = Some("view.zoom"); + if let Ok(p) = s.parse::() { + self.values.insert("percent".into(), json!(p)); + } + } + None => {} + }, + "percent" => { + self.command = Some("view.zoom"); + self.values.insert("preset".into(), json!(zoom_preset(value.as_f64().unwrap_or(0.0)))); + } + _ => {} + } } self.values.insert(key.into(), value); } @@ -522,20 +537,19 @@ impl Dialog { ], json!({"groupBy": "A", "function": "sum"}), ), - "zoom" => Dialog::form( - "zoom", - "Zoom", - "view.zoom", - vec![ - Choice { - key: "preset", - label: "Magnification", - options: vec![("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")], - }, - Number { key: "percent", label: "Custom (%):" }, - ], - json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}), - ), + "zoom" => { + // The current zoom is pre-selected (Custom when it matches no preset). Fit + // selection needs the grid's size, so it is captured here for `view.zoomToSelection`. + let zoom = app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100); + let view = app.grid.cells_rect.unwrap_or(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1200.0, 700.0))); + Dialog::form( + "zoom", + "Zoom", + "view.zoom", + vec![], + json!({"percent": zoom, "preset": zoom_preset(zoom as f64), "viewWidth": view.width(), "viewHeight": view.height()}), + ) + } "defineName" => { // Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and // its current fields; New… and Define Name start from the selection. @@ -983,13 +997,6 @@ fn build_params(d: &Dialog) -> Json { } } } - "zoom" => { - if let Some(p) = d.values.get("preset").and_then(Json::as_str) - && d.values.get("percent").is_none() - { - v["percent"] = json!(p.parse::().unwrap_or(100.0)); - } - } "cfQuick" => { let mut rule = Map::new(); for (k, val) in &d.values { @@ -1033,6 +1040,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, + "zoom" => 220.0, _ => 380.0, }; let window = egui::Window::new(app.ui.language.tr(&d.title)) @@ -1051,6 +1059,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), "find" => find(app, ui, &mut d), "goTo" => go_to(app, ui, &mut d, &mut confirm), + "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), "sort" => sort(app, ui, &mut d, &mut confirm), "nameManager" => name_manager(app, ui, &mut d, &mut open), "manageRules" => manage_rules(app, ui), @@ -1890,6 +1899,43 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. +const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; + +/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. +fn zoom_preset(percent: f64) -> &'static str { + ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") +} + +/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and +/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage +/// selects Custom; Fit selection zooms so the selected cells fill the grid. +fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); + ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); + for (val, text) in ZOOM_PRESETS { + if ui.radio(cur == val, text).clicked() { + d.set_value("preset", json!(val)); + } + } + if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { + d.set_value("preset", json!("fit")); + } + ui.horizontal(|ui| { + if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { + d.set_value("preset", json!("custom")); + } + // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. + let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; + if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { + d.set_value("percent", json!(percent)); + } + ui.label("%"); + }); + ok_cancel(ui, confirm, open); +} + fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let names: Vec = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); for n in names { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..1833335f4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -386,6 +386,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Gridlines", "目盛線"), ("Headings", "見出し"), ("Zoom", "ズーム"), + ("Magnification", "倍率"), + ("Fit selection", "選択範囲に合わせる"), ("100%", "100%"), ("Zoom to\nSelection", "選択範囲を拡大"), ("Freeze\nPanes", "ウィンドウ枠の固定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..5c7c894dd 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Magnification" => "Měřítko", + "Fit selection" => "Přizpůsobit výběru", "Forecast\nSheet" => "List\nprognózy", "Add footer" => "Přidat zápatí", "Add header" => "Přidat záhlaví", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..8ad57abf2 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Magnification" => "Vergrößerung", + "Fit selection" => "An Markierung anpassen", "Forecast\nSheet" => "Prognose-\nblatt", "Add footer" => "Fußzeile hinzufügen", "Add header" => "Kopfzeile hinzufügen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..a2ceaef9b 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -193,6 +193,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Magnification" => "Ampliación", + "Fit selection" => "Ajustar la selección", "Forecast\nSheet" => "Hoja de\npronóstico", "Add footer" => "Agregar pie de página", "Add header" => "Agregar encabezado", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..3fb708871 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -214,6 +214,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Quadrillage", "Headings" => "En-têtes", "Zoom" => "Zoom", + "Magnification" => "Zoom", + "Fit selection" => "Ajuster la sélection", "100%" => "100 %", "Zoom to\nSelection" => "Zoom sur la\nsélection", "Freeze\nPanes" => "Figer les\nvolets", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..77052d208 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -211,6 +211,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "눈금선", "Headings" => "머리글", "Zoom" => "확대/축소", + "Magnification" => "배율", + "Fit selection" => "선택 영역에 맞춤", "100%" => "100%", "Zoom to\nSelection" => "선택 영역에\n맞춤", "Freeze\nPanes" => "틀\n고정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..e1751cff7 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -212,6 +212,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Linhas de Grade", "Headings" => "Títulos", "Zoom" => "Zoom", + "Fit selection" => "Ajustar seleção", "100%" => "100%", "Zoom to\nSelection" => "Zoom na\nSeleção", "Freeze\nPanes" => "Congelar\nPainéis", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..de0b485a2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -211,6 +211,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Сетка", "Headings" => "Заголовки", "Zoom" => "Масштаб", + "Magnification" => "Масштаб", + "Fit selection" => "По выделению", "100%" => "100%", "Zoom to\nSelection" => "Масштаб по\nвыделению", "Freeze\nPanes" => "Закрепить\nобласти", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..1209ecfa3 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -206,6 +206,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Linije mreže", "Headings" => "Zaglavlja", "Zoom" => "Uvećanje", + "Magnification" => "Uvećanje", + "Fit selection" => "Uklopi izbor", "100%" => "100%", "Zoom to\nSelection" => "Uvećaj na\nselekciju", "Freeze\nPanes" => "Zamrzni\nokna", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..df9b7acbf 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -211,6 +211,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "网格线", "Headings" => "标题", "Zoom" => "缩放", + "Magnification" => "缩放比例", + "Fit selection" => "恰好容纳选定区域", "100%" => "100%", "Zoom to\nSelection" => "缩放到\n所选内容", "Freeze\nPanes" => "冻结\n窗格", diff --git a/crates/ui-egui/tests/zoom_dialog.rs b/crates/ui-egui/tests/zoom_dialog.rs index f5be90833..f300d68f0 100644 --- a/crates/ui-egui/tests/zoom_dialog.rs +++ b/crates/ui-egui/tests/zoom_dialog.rs @@ -83,3 +83,65 @@ fn a_custom_percentage_after_a_magnification_wins_and_an_unedited_ok_keeps_the_z control(&mut h, "ui.dialog.confirm", json!({})); assert_eq!(zoom(&h), 225); } + +#[test] +fn the_current_zoom_is_preselected_as_its_magnification_or_as_custom() { + let mut h = harness(); + h.state_mut().open_dialog("zoom", json!({})); + h.run_steps(2); + let d = h.state().dialog.clone().unwrap(); + assert_eq!(d.values["preset"], json!("100")); + assert_eq!(d.values["percent"], json!(100)); + control(&mut h, "ui.dialog.cancel", json!({})); + h.state_mut().run("view.zoom", json!({"percent": 130})).unwrap(); + h.state_mut().open_dialog("zoom", json!({})); + h.run_steps(2); + let d = h.state().dialog.clone().unwrap(); + assert_eq!(d.values["preset"], json!("custom")); + assert_eq!(d.values["percent"], json!(130)); + // An unedited OK keeps the zoom. + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(zoom(&h), 130); +} + +#[test] +fn a_typed_percentage_selects_custom_and_sets_that_zoom() { + let mut h = harness(); + control(&mut h, "ui.dialog", json!({"name": "zoom"})); + control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"})); + control(&mut h, "ui.dialog.set", json!({"field": "percent", "value": 150})); + assert_eq!(h.state().dialog.as_ref().unwrap().values["preset"], json!("custom")); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(zoom(&h), 150); +} + +#[test] +fn fit_selection_zooms_so_the_selected_cells_fill_the_grid() { + let mut h = harness(); + h.state_mut().run("selection.set", json!({"range": "A1:J40"})).unwrap(); + h.run_steps(2); + let view = h.state().grid.cells_rect.unwrap(); + h.state_mut().open_dialog("zoom", json!({})); + h.run_steps(6); + h.get_by_label("Fit selection").click(); + h.run_steps(6); + h.get_by_label(" OK ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + // The same answer the ribbon's Zoom to Selection gives for this grid. + let mut expected = Session::new(); + expected.new_workbook(); + expected.execute("selection.set", json!({"range": "A1:J40"})).unwrap(); + let r = expected.execute("view.zoomToSelection", json!({"viewWidth": view.width(), "viewHeight": view.height()})).unwrap(); + assert_eq!(json!(zoom(&h)), r["zoom"]); + assert!(zoom(&h) > 10 && zoom(&h) < 100, "{}", zoom(&h)); + // Fit selection over the control channel, before any frame is drawn, uses the grid size captured on opening. + h.state_mut().run("selection.set", json!({"range": "A1"})).unwrap(); + control(&mut h, "ui.dialog", json!({"name": "zoom"})); + control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(zoom(&h), 400); +}