diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3116a4da9..ada19d3a4 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); } @@ -526,20 +541,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. @@ -987,13 +1001,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 { @@ -1037,6 +1044,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)) @@ -1057,6 +1065,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), @@ -1896,6 +1905,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 98919f8a4..4a2cd6fe9 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 371ea7981..1f5264bb3 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 82027e847..b039a37b6 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 85bd2da72..8d0f83e99 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); +}