Merge PR #1277: Zoom dialog: the reference app's layout, current zoom pre-selected, Fit selection (#930)

This commit is contained in:
aiongg
2026-10-22 02:35:00 +00:00
12 changed files with 154 additions and 27 deletions
+73 -27
View File
@@ -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::<f64>().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::<f64>() {
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::<f64>().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::<f64>().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<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
for n in names {
+2
View File
@@ -386,6 +386,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Gridlines", "目盛線"),
("Headings", "見出し"),
("Zoom", "ズーム"),
("Magnification", "倍率"),
("Fit selection", "選択範囲に合わせる"),
("100%", "100%"),
("Zoom to\nSelection", "選択範囲を拡大"),
("Freeze\nPanes", "ウィンドウ枠の固定"),
+2
View File
@@ -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í",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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고정",
+1
View File
@@ -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",
+2
View File
@@ -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области",
+2
View File
@@ -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",
+2
View File
@@ -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窗格",
+62
View File
@@ -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);
}