diff --git a/ROADMAP.md b/ROADMAP.md index 9acd2e13f..3d65350f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors | | 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run | | 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | | 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else | @@ -256,6 +257,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | | 2026-11-02 | minor | Progress log: Python mode in the formula bar | | 2026-11-02 | minor | Progress log: Python cells as data | | 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) | diff --git a/crates/math/tests/gate.rs b/crates/math/tests/gate.rs index 1b7d7c7f0..88ecc764c 100644 --- a/crates/math/tests/gate.rs +++ b/crates/math/tests/gate.rs @@ -489,7 +489,8 @@ fn pathological_source_at_the_cap_finishes_in_bounded_time() { fill("\\pu{", "10^", "}"), fill("\\pu{", "kJ//", "}"), ]; - let bound = Duration::from_secs(10); + // A CI regression threshold with headroom for shared-runner variance, not a runtime deadline. + let bound = Duration::from_secs(15); let mut slow = Vec::new(); for s in &sources { let start = Instant::now(); diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 6c5dcaaa7..5474ddc84 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -2,7 +2,10 @@ //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] //! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr] -//! [--pane name] [--object id] [--dialog 'name={json}'] [--hf-edit header|footer] out.png` +//! [--pane name] [--object id] [--dialog 'name={json}'] [--refedit key=A1:B2] [--hf-edit header|footer] out.png` +//! +//! `--refedit key=range` collapses the open dialog to its reference box `key`, then selects +//! `range` on the grid, as a person picking a range for that box does. use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -22,6 +25,7 @@ fn main() { let mut dialog: Option = None; let mut language: Option = None; let mut hf_edit: Option = None; + let mut refedit: Option = None; let mut out = "snapshot.png".to_string(); let mut i = 0; while i < args.len() { @@ -68,6 +72,10 @@ fn main() { i += 1; dialog = args.get(i).cloned(); } + "--refedit" => { + i += 1; + refedit = args.get(i).cloned(); + } "--hf-edit" => { i += 1; hf_edit = args.get(i).cloned(); @@ -134,6 +142,15 @@ fn main() { // The workbook's installed fonts bind once the font scan is done; frames past the first few // apply them, so the snapshot shows them as the app does. harness.run_steps(14); + if let Some((key, range)) = refedit.as_deref().and_then(|r| r.split_once('=')) { + let app = harness.state_mut(); + if let Some(mut d) = app.dialog.take() { + d.collapse_to(app, key); + app.dialog = Some(d); + } + app.session.execute("selection.set", json!({"range": range})).expect("select"); + harness.run_steps(8); + } let img = harness.render().expect("render"); img.save(&out).expect("save png"); eprintln!("wrote {out} ({}x{})", img.width(), img.height()); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index a2f47c67f..86a4a6cc5 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -312,8 +312,12 @@ impl SheetApp { } /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect - /// Sheet labels and actions). + /// Sheet labels and actions). While a dialog's reference boxes have picked, it is the dialog's + /// own sheet: Protect Sheet ends the pick and acts on that sheet, so the label says so too. pub fn active_sheet_protected(&self) -> bool { + if let Some(protected) = self.dialog.as_ref().and_then(|d| d.home_sheet_protected(self)) { + return protected; + } self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) } @@ -322,6 +326,20 @@ impl SheetApp { self.message = Some(("Text Box".into(), clean_error(&e))); return; } + // A protection action taken while a reference box picks on another sheet is for the + // dialog's sheet: the pick ends, showing that sheet again, before the action is chosen. + if matches!(name, "protectSheet" | "unprotectSheet") + && let Some(mut d) = self.dialog.take() + { + let error = + if d.home_sheet_deleted(self) { Some(dialogs::SHEET_DELETED.into()) } else { d.end_pick(self).err().map(|e| clean_error(&e)) }; + if let Some(error) = error { + self.message = Some((d.title.clone(), error)); + self.dialog = Some(d); + return; + } + self.dialog = Some(d); + } // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet // on a protected sheet. A sheet without a password is unprotected straight away. let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; @@ -376,7 +394,16 @@ impl SheetApp { "Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(), )); } - other => self.dialog = dialogs::Dialog::open(self, other, params), + other => { + // A dialog replaced while one of its reference boxes picks (a ribbon command + // opening another dialog) first puts the sheet and selection from before picking + // back, as closing it does. + if let Some(mut old) = self.dialog.take() { + // The draft was committed above, so the sheet can be shown again. + let _ = old.end_pick(self); + } + self.dialog = dialogs::Dialog::open(self, other, params); + } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3a29f6d1b..2d46b21b8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -180,6 +180,34 @@ fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json { json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python}) } +/// `ui.dialog.cancel`: closes the open dialog as its Cancel button does, going back to the sheet +/// and selection from before a reference box picked. +fn dialog_cancel(app: &mut SheetApp) -> Outcome { + // The macro step editor's draft, overlays and debug run go with its own close. + if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + return match crate::macro_editor::close_now(app) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + }; + } + // Back to the sheet and selection from before a reference box picked, as Cancel does. + // When the dialog's sheet can't be shown again (a text-box draft on the picked sheet + // could not be saved), the dialog stays and the script gets the reason; the window's + // Cancel closes it and shows the reason, since a person can see the message. + // A dialog whose own sheet was deleted (found by identity, so a new sheet given its + // name does not count) replies `{"sheetDeleted": true}`: the sheet shown is not the + // dialog's, and a script stops before running the dialog's command itself. + let mut deleted = false; + if let Some(mut d) = app.dialog.take() { + deleted = d.home_sheet_deleted(app); + if let Err(e) = d.end_pick(app) { + app.dialog = Some(d); + return err(e); + } + } + ok(if deleted { json!({"sheetDeleted": true}) } else { Json::Null }) +} + /// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind /// is found from the id when left out, and must match when given); no `id` clears the selection. /// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`. @@ -309,8 +337,21 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - None => err("no dialog is open"), }, "ui.dialog.confirm" => match app.dialog.take() { - Some(d) => match d.command { + Some(mut d) => match d.command { Some(cmd) => { + // References without a sheet name mean the sheet the dialog was opened on, + // as OK in the window has it: refused once that sheet is deleted, else shown + // again before the command runs. + if d.home_sheet_deleted(app) { + app.dialog = Some(d); + return err(crate::dialogs::SHEET_DELETED); + } + if let Err(e) = d.end_pick(app) { + // The dialog's sheet can't be shown again (a text-box draft on the picked + // sheet could not be saved): the command stays unrun, the dialog open. + app.dialog = Some(d); + return err(e); + } let params = crate::dialogs::build_params(&d); let home = app.ui_window(); let r = app.run(cmd, params); @@ -326,17 +367,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - }, None => err("no dialog is open"), }, - "ui.dialog.cancel" => { - // The macro step editor's draft, overlays and debug run go with its own close. - if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { - return match crate::macro_editor::close_now(app) { - Ok(()) => ok(Json::Null), - Err(e) => err(e), - }; - } - app.dialog = None; - ok(Json::Null) - } + "ui.dialog.cancel" => dialog_cancel(app), "ui.object.select" => select_object(app, p), "ui.message.dismiss" => { app.message = None; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 71c68249b..33be245a5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -24,6 +24,7 @@ mod format_cells; mod insert_function; mod names; mod paste; +mod ref_edit; mod show; mod spelling; mod zoom; @@ -34,13 +35,17 @@ use data::{abs, data_validation_fields, remove_duplicates_range, validation_defa use find::go_to_entries; pub(crate) use names::insert_name; use names::pasteable_names; +pub(crate) use ref_edit::SHEET_DELETED; pub(crate) use show::build_params; pub use show::show; use zoom::zoom_preset; +/// A form field. `Ref` is a range reference that can also be picked by selecting on the grid +/// (a RefEdit box). #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, + Ref { key: &'static str, label: &'static str }, Password { key: &'static str, label: &'static str }, Number { key: &'static str, label: &'static str }, Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> }, @@ -74,6 +79,8 @@ pub struct Dialog { pick: Option, /// The linked cell's initial display text; unchanged text must not replace a number or formula. original_link_text: Option, + /// The state of the dialog's reference boxes (RefEdit). + refs: ref_edit::RefBoxes, } /// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute @@ -127,6 +134,39 @@ impl Dialog { self.values.insert(key.into(), value); } + /// The reference box the dialog is collapsed to, if any. + pub fn collapsed(&self) -> Option<&str> { + self.refs.collapsed.as_deref() + } + + /// Collapses the dialog to its reference box `key` and starts picking into it, as the box's + /// collapse button does. + pub fn collapse_to(&mut self, app: &SheetApp, key: &str) { + ref_edit::begin_pick(app, self, key); + self.refs.collapsed = Some(key.to_string()); + } + + /// Ends picking into the dialog's reference boxes and puts back the sheet and selection from + /// before picking began, as closing the dialog does. Fails when the dialog's sheet could not + /// be shown again (a text-box draft on the picked sheet could not be saved): a dialog about + /// to run its command must then stay open instead. + pub(crate) fn end_pick(&mut self, app: &mut SheetApp) -> Result<(), String> { + ref_edit::end_pick(app, self) + } + + /// Whether the dialog's own sheet is protected, once its reference boxes have picked and while + /// that sheet exists (the sheet [`Self::end_pick`] shows again); `None` otherwise. + pub(crate) fn home_sheet_protected(&self, app: &SheetApp) -> Option { + ref_edit::home_sheet_protected(app, self) + } + + /// Whether the sheet the dialog was opened on was deleted while its reference boxes picked: + /// OK is then refused with [`SHEET_DELETED`], since references without a sheet name have + /// nothing to refer to. + pub(crate) fn home_sheet_deleted(&mut self, app: &SheetApp) -> bool { + ref_edit::home_sheet_deleted(app, self) + } + /// Whether a click on the grid goes to a formula box of this dialog rather than the selection. pub fn picks_references(&self) -> bool { self.pick.is_some() @@ -187,6 +227,7 @@ impl Dialog { focus_initialized: false, pick: None, original_link_text: None, + refs: ref_edit::RefBoxes::default(), } } @@ -210,6 +251,7 @@ impl Dialog { focus_initialized: false, pick: None, original_link_text: None, + refs: ref_edit::RefBoxes::default(), } } diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs index 2ead47af8..df1d62c67 100644 --- a/crates/ui-egui/src/dialogs/analysis.rs +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -1,34 +1,43 @@ +use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, ref_edit}; use super::show::{build_params, form_fields, ok_cancel}; use super::{Dialog, Field, Json, Map, SheetApp, json, vec2}; -pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); +/// Data › What-If Analysis › Goal Seek. OK runs `data.goalSeek` here; Cancel clears `open`, as +/// the shared buttons do, so cancelling is never taken for a confirmation. +pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { egui::Grid::new("gs").show(ui, |ui| { - ui.label("Set cell:"); - ui.text_edit_singleline(&mut set); + let l = ui.label("Set cell:"); + ref_edit(app, ui, d, "set", RefLabel::By(l.id), 200.0); ui.end_row(); - ui.label("To value:"); - ui.text_edit_singleline(&mut to); + let l = ui.label("To value:"); + let mut to = d.values.get("to").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut to).desired_width(170.0)).labelled_by(l.id).changed() { + d.values.insert("to".into(), json!(to)); + } ui.end_row(); - ui.label("By changing cell:"); - ui.text_edit_singleline(&mut changing); + let l = ui.label("By changing cell:"); + ref_edit(app, ui, d, "changing", RefLabel::By(l.id), 200.0); ui.end_row(); }); - d.values.insert("set".into(), json!(set.clone())); - d.values.insert("to".into(), json!(to.clone())); - d.values.insert("changing".into(), json!(changing.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let (set, to, changing) = (get("set"), get("to"), get("changing")); + ok_cancel(ui, confirm, open); + if *confirm && home_sheet_deleted(app, d) { + app.message = Some((d.title.clone(), SHEET_DELETED.into())); + *confirm = false; + } + if *confirm && let Err(e) = end_pick(app, d) { + // The dialog's sheet can't be shown again (a draft on the picked sheet could not be + // saved): the cells would be read against the wrong sheet, so Goal Seek doesn't run. + app.message = Some((d.title.clone(), crate::clean_error(&e))); + *confirm = false; + } if *confirm { match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), } } - if !open { - *confirm = true; - } } /// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). @@ -36,8 +45,8 @@ fn toolpak_fields(tool: &str, values: &Map) -> Vec { use Field::*; let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; let labels = || Check { key: "labels", label: "Labels in first row" }; - let range = || Text { key: "range", label: "Input Range:" }; - let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; + let range = || Ref { key: "range", label: "Input Range:" }; + let two = || vec![Ref { key: "range1", label: "Variable 1 Range:" }, Ref { key: "range2", label: "Variable 2 Range:" }]; match tool { "descriptive" => vec![ range(), @@ -50,7 +59,7 @@ fn toolpak_fields(tool: &str, values: &Map) -> Vec { ], "histogram" => vec![ range(), - Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, + Ref { key: "bins", label: "Bin Range (blank for automatic bins):" }, labels(), Check { key: "pareto", label: "Pareto (sorted histogram)" }, Check { key: "cumulative", label: "Cumulative Percentage" }, @@ -69,8 +78,8 @@ fn toolpak_fields(tool: &str, values: &Map) -> Vec { Text { key: "alpha", label: "Alpha:" }, ], "regression" => vec![ - Text { key: "inputY", label: "Input Y Range:" }, - Text { key: "inputX", label: "Input X Range:" }, + Ref { key: "inputY", label: "Input Y Range:" }, + Ref { key: "inputX", label: "Input X Range:" }, labels(), Check { key: "constantZero", label: "Constant is Zero" }, Text { key: "confidence", label: "Confidence Level (%):" }, @@ -262,19 +271,36 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di // The tool's options scroll in a short window (the patterned Random Number Generation form // is taller than 600 points); the output options and OK below always stay in view. let room = (ui.ctx().content_rect().height() - TOOLPAK_CHROME).max(120.0); - egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(ui, d)); + egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(app, ui, d)); ui.separator(); ui.label(egui::RichText::new("Output options").strong()); let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { ui.horizontal(|ui| { - if ui.radio(to == choice, text_label).clicked() { + let r = ui.radio(to == choice, text_label); + if r.clicked() { d.values.insert("outputTo".into(), json!(choice)); + // Another output option ends picking into Output Range, so a later selection on + // the grid doesn't choose it again. + if key != "output" && d.refs.target.as_deref() == Some("output") { + d.refs.target = None; + } } - let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { - d.values.insert(key.into(), json!(t)); - d.values.insert("outputTo".into(), json!(choice)); + if key == "output" { + let before = d.values.get(key).cloned(); + let was_target = d.refs.target.as_deref() == Some(key); + ref_edit(app, ui, d, key, RefLabel::By(r.id), 190.0); + // Clicking into the box, typing or picking an output range chooses it; only a + // change does, so an option chosen later stays chosen. + if d.refs.target.as_deref() == Some(key) && (!was_target || d.values.get(key) != before.as_ref()) { + d.values.insert("outputTo".into(), json!(choice)); + } + } else { + let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { + d.values.insert(key.into(), json!(t)); + d.values.insert("outputTo".into(), json!(choice)); + } } }); } @@ -377,6 +403,23 @@ fn clear_solver_options(d: &mut Dialog) { d.values.remove("requireBounds"); } +/// Before one of Solver's commands other than Solve runs from the form (Reset All, Load, Save, +/// Replace): the dialog's sheet must be there and shown again, as `dialogs::show` requires for +/// Solve, since the commands act on the active sheet and read references without a sheet name +/// against it. Shows the reason and answers `false` when the command must not run: the sheet +/// was deleted, or it cannot be shown again (a draft on the picked sheet could not be saved). +fn at_home(app: &mut SheetApp, d: &mut Dialog) -> bool { + if home_sheet_deleted(app, d) { + app.message = Some((d.title.clone(), SHEET_DELETED.into())); + return false; + } + if let Err(e) = end_pick(app, d) { + app.message = Some((d.title.clone(), crate::clean_error(&e))); + return false; + } + true +} + /// Save Model from the Solver Parameters form: writes the form's model down from `range`. A /// range that holds data is replaced only with `overwrite`; without it the form asks first. fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) { @@ -410,8 +453,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf return; } ui.horizontal(|ui| { - ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0, "Set Objective:"); + let l = ui.label("Set Objective:"); + ref_edit(app, ui, d, "objective", RefLabel::By(l.id), 230.0); }); let goal = text(d, "goal"); ui.horizontal(|ui| { @@ -423,8 +466,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf } edit(ui, d, "value", 80.0, "Value Of:"); }); - ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); + let l = ui.label("By Changing Variable Cells:"); + ref_edit(app, ui, d, "variables", RefLabel::By(l.id), 420.0); ui.label("Subject to the Constraints:"); let list: Vec = d .values @@ -443,7 +486,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf }); }); ui.horizontal(|ui| { - edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); + ref_edit(app, ui, d, "newLhs", RefLabel::Named("Constraint Cell Reference"), 130.0); let op = text(d, "newOp"); craft_ui::ComboBox::from_id_salt("solverOp") .selected_text(op.clone()) @@ -458,7 +501,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf .response .accessible_name(lang.tr("Constraint Operator")); if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0, "Constraint Value"); + ref_edit(app, ui, d, "newRhs", RefLabel::Named("Constraint Value"), 130.0); } if ui.button("Add").clicked() { let lhs = text(d, "newLhs"); @@ -513,7 +556,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf ); ui.add_space(4.0); ui.horizontal(|ui| { - if ui.button("Reset All").clicked() { + if ui.button("Reset All").clicked() && at_home(app, d) { match app.run("data.solverReset", json!({})) { Ok(_) => { for (k, v) in [ @@ -539,6 +582,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf } } let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false); + // Hiding the area ends picking into Model Range, as a box that is not drawn is no longer + // picked into (`ref_edit::window_shown`). if ui.selectable_label(shown, "Load/Save").clicked() { d.values.insert("modelArea".into(), json!(!shown)); } @@ -548,11 +593,11 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf }); if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) { ui.horizontal(|ui| { - ui.label("Model Range:"); - edit(ui, d, "modelRange", 140.0, "Model Range:"); + let l = ui.label("Model Range:"); + ref_edit(app, ui, d, "modelRange", RefLabel::By(l.id), 170.0); let range = text(d, "modelRange"); let ready = !range.trim().is_empty(); - if ui.add_enabled(ready, egui::Button::new("Load")).clicked() { + if ui.add_enabled(ready, egui::Button::new("Load")).clicked() && at_home(app, d) { d.values.remove("askModelOverwrite"); match app.run("data.solverLoadModel", json!({"from": range.trim()})) { Ok(r) => { @@ -570,7 +615,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf } } } - if ui.add_enabled(ready, egui::Button::new("Save")).clicked() { + if ui.add_enabled(ready, egui::Button::new("Save")).clicked() && at_home(app, d) { save_solver_model(app, d, &range, false); } }); @@ -583,7 +628,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf } else if let Some(asked) = asked { ui.horizontal_wrapped(|ui| { ui.label(format!("{asked} already holds data. Replace it with the model?")); - if ui.button("Replace").clicked() { + if ui.button("Replace").clicked() && at_home(app, d) { save_solver_model(app, d, &asked, true); } if ui.button("Don't Replace").clicked() { diff --git a/crates/ui-egui/src/dialogs/data.rs b/crates/ui-egui/src/dialogs/data.rs index ec5eabfc1..e3eabda79 100644 --- a/crates/ui-egui/src/dialogs/data.rs +++ b/crates/ui-egui/src/dialogs/data.rs @@ -150,15 +150,15 @@ pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec { fields } -pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { +pub(super) fn data_validation_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); d.fields = data_validation_fields(kind, operator); - form_fields(ui, d); + form_fields(app, ui, d); ok_cancel(ui, confirm, open); } -pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { +pub(super) fn advanced_filter_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { use Field::*; let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); d.fields = vec![ @@ -174,7 +174,7 @@ pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: & d.fields.push(Text { key: "copyTo", label: "Copy to:" }); } d.fields.push(Check { key: "unique", label: "Unique rows only" }); - form_fields(ui, d); + form_fields(app, ui, d); // Constrain the button row: the shared right-to-left layout otherwise // consumes the window's remaining height instead of fitting the buttons. ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); diff --git a/crates/ui-egui/src/dialogs/ref_edit.rs b/crates/ui-egui/src/dialogs/ref_edit.rs new file mode 100644 index 000000000..8cd3009a2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/ref_edit.rs @@ -0,0 +1,486 @@ +//! Reference boxes (Excel's RefEdit): text fields that take a range by selection on the grid, with +//! a button that collapses the dialog to the box while the person selects. + +use gridcraft_engine::model::{Sheet, SheetUid, Workbook}; + +use super::data::abs; +use super::{Dialog, Icon, Json, SheetApp, json, theme, vec2}; + +/// What a dialog's reference boxes are doing: which one grid selections fill, and whether the +/// dialog is collapsed to one of them. +#[derive(Clone, Debug, Default)] +pub(super) struct RefBoxes { + /// The reference box that grid selections are written into. + pub(super) target: Option, + /// The selection last written into the target (or present when picking began); only a + /// different selection replaces what the box holds. + seen: String, + /// The sheet shown when `seen` was taken: changing sheets only refreshes `seen`, since the + /// other sheet's own selection is not a pick. + seen_sheet: String, + /// The dialog's own sheet, from the first pick until the dialog closes, and the selection to + /// put back when it runs its command or closes. + home: Option, + /// The target took a selection from the grid and takes the keyboard back once the pointer + /// is released, so typing still edits the box. + refocus: bool, + /// The target box was drawn this frame. A box the dialog stops showing (another view of the + /// dialog, a hidden area, a constraint operator that takes no value) is no longer picked + /// into, so a selection made meanwhile does not land in it once it is shown again (see + /// [`window_shown`]). + target_drawn: bool, + /// The reference box the dialog is collapsed to. + pub(super) collapsed: Option, + /// Where the expanded dialog was last drawn: the collapsed dialog keeps its top-left corner, + /// and the expanded one comes back where it was. + rect: Option, + /// The dialog was drawn collapsed last frame. + was_collapsed: bool, + /// A text field other than the collapsed box (the Name Box, say) owned the keyboard when + /// the last frame ended: its keys are its own, not the collapsed picker's (see + /// [`place_window`]). Kept from the frame before because egui drops the focus for Escape + /// before anything is drawn, so the frame of the Escape no longer says who had it. + other_field: bool, +} + +/// Where picking began: the dialog's own sheet, kept for as long as the dialog is open (a failed +/// OK leaves it open, and a later OK still needs to know whether that sheet is there), and the +/// selection to put back once. +#[derive(Clone, Debug)] +struct Home { + /// The sheet's identity while the app runs ([`SheetUid`]): fresh for every sheet added, + /// inserted, copied or read, and kept through renames, moves, undo and redo. The sheet is + /// found by it whatever happened meanwhile ([`home_sheet`]): a sheet given its name or a + /// copy of it is never taken for it, and an undo that brings it back makes it the dialog's + /// again. + uid: SheetUid, + /// The ranges and active cell from before picking began on the dialog's sheet, put back once + /// when the dialog runs its command or closes. + selection: Option<(String, String)>, +} + +/// The dialog's own sheet in `wb`, or `None` once it was deleted. +fn home_sheet<'a>(wb: &'a Workbook, home: &Home) -> Option<&'a Sheet> { + wb.sheets.iter().map(|s| &**s).find(|s| s.uid.get() == home.uid.get()) +} + +/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while +/// that sheet exists: the sheet [`end_pick`] shows again. `None` otherwise. +pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option { + let home = d.refs.home.as_ref()?; + app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(Sheet::is_protected) +} + +/// The message OK shows once the dialog's own sheet is gone. +pub(crate) const SHEET_DELETED: &str = + "The sheet this dialog was opened on was deleted. Cancel, then open the dialog again on the sheet it should use."; + +/// Whether the sheet the dialog was opened on has been deleted while it picked: its references +/// without a sheet name have nothing to refer to, so OK is refused with [`SHEET_DELETED`]. +pub(super) fn home_sheet_deleted(app: &SheetApp, d: &mut Dialog) -> bool { + d.refs.home.as_ref().is_some_and(|h| app.session.active().is_none_or(|doc| home_sheet(&doc.wb, h).is_none())) +} + +/// How a reference box is named for screen readers. +#[derive(Clone, Copy)] +pub(super) enum RefLabel<'a> { + /// By the label drawn beside it. + By(egui::Id), + /// By an English name, translated (a box without a label of its own). + Named(&'a str), +} + +/// The egui id of the reference box `key`, the same wherever the box is drawn. +fn ref_edit_id(key: &str) -> egui::Id { + egui::Id::unique("gridcraft.ref_edit").with(key) +} + +/// Whether the reference box `key` of dialog `dialog` takes several areas joined by commas. +/// Solver's variable cells do; every other box takes one cell or range, which its command parses +/// as a single reference. +fn takes_several_areas(dialog: &str, key: &str) -> bool { + dialog == "solver" && key == "variables" +} + +/// The grid selection as a reference, with the sheet name when the selection is on another sheet +/// than the one the dialog was opened on: every area joined by commas when `several`, otherwise +/// only the area being selected (the last one). +fn selection_ref(app: &SheetApp, home: Option<&Home>, several: bool) -> String { + let Some(doc) = app.session.active() else { return String::new() }; + let current = [doc.selection.current()]; + let areas = if several { doc.selection.ranges.as_slice() } else { ¤t[..] }.iter().map(abs); + match doc.wb.active() { + // Once the dialog's own sheet is deleted, every reference carries its sheet's name. + Some(sh) if home.is_some_and(|h| h.uid.get() != sh.uid.get()) => { + let sheet = gridcraft_engine::formula::quote_sheet(&sh.name); + areas.map(|a| format!("{sheet}!{a}")).collect::>().join(",") + } + _ => areas.collect::>().join(","), + } +} + +/// Places the dialog's window before it is drawn, and returns the reference box the dialog is +/// collapsed to. Escape or Enter expands a collapsed dialog again, rather than closing it or +/// pressing OK, and the arrow keys select on the grid, unless another text field (the Name Box) +/// owns the keyboard: its keys stay its own, as they were before the dialog collapsed. +/// Collapsed, the dialog shrinks to the box where its top-left corner was, leaving the sheet +/// free for selecting; expanded again, it comes back where it was. +pub(super) fn place_window<'a>( + app: &mut SheetApp, + ctx: &egui::Context, + d: &mut Dialog, + window: egui::Window<'a>, + window_id: egui::Id, +) -> (egui::Window<'a>, Option) { + if let Some(key) = d.refs.collapsed.as_deref() { + let other_field = d.refs.other_field || other_field_focused(ctx, key); + // Escape and Enter are used up either way: left in the frame's input, the dialog would + // read them as its own and close or confirm. When another field owned them, that field + // (drawn before the dialogs) has read them already, and nothing more happens here. + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) | i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + if !other_field { + d.refs.collapsed = None; + } + } else if !other_field { + arrows_move_selection(app, ctx); + } + } + let collapsed = d.refs.collapsed.clone(); + if collapsed.is_none() + && d.refs.was_collapsed + && let Some(rect) = d.refs.rect + && let Some(mut state) = egui::AreaState::load(ctx, window_id) + { + // The window's stored position wins over `current_pos` for a window moved by its title + // bar, so the expanded position is written into its state. + state.pivot = egui::Align2::CENTER_CENTER; + state.pivot_pos = Some(rect.center()); + ctx.memory_mut(|m| m.areas_mut().set_state(egui::LayerId::new(egui::Order::Middle, window_id), state)); + } + let window = match (&collapsed, d.refs.rect) { + (Some(_), Some(rect)) => window.pivot(egui::Align2::LEFT_TOP).fixed_pos(rect.min), + _ => window, + }; + (window, collapsed) +} + +/// Whether a text field other than the reference box `key` has the keyboard. +fn other_field_focused(ctx: &egui::Context, key: &str) -> bool { + ctx.text_edit_focused() && ctx.memory(|m| m.focused()) != Some(ref_edit_id(key)) +} + +/// While the dialog is collapsed, the arrow keys move the selection on the grid, Shift+arrow +/// extends it and Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data, as they do with no +/// dialog open: the box follows the selection as it does for the mouse. The keys are taken out +/// of the frame's input before the box's text field reads them, so they don't move its caret +/// instead; expanded, they edit the box. +fn arrows_move_selection(app: &mut SheetApp, ctx: &egui::Context) { + use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp}; + let mut moves = Vec::new(); + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key: key @ (ArrowUp | ArrowDown | ArrowLeft | ArrowRight), pressed: true, modifiers, .. } => { + moves.push((*key, *modifiers)); + false + } + _ => true, + }); + }); + if moves.is_empty() { + return; + } + // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.right_to_left); + for (key, m) in moves { + let (dr, dc) = match key { + ArrowUp => (-1, 0), + ArrowDown => (1, 0), + ArrowLeft => (0, if rtl { 1 } else { -1 }), + _ => (0, if rtl { -1 } else { 1 }), + }; + let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active }); + let _ = app.run("selection.move", json!({"dr": dr, "dc": dc, "extend": m.shift, "jump": m.command})); + app.grid.ensure_visible = true; + app.grid.reveal_moving_end = m.shift; + app.grid.reveal_from = from; + } +} + +/// After the dialog's window is drawn: remembers where the expanded window is, and ends picking +/// into a reference box when the box was not drawn (the dialog stopped showing it) or when +/// another text field of the dialog takes the keyboard. A collapsed dialog shows no other field, +/// so its box keeps picking whatever else is clicked (the Name Box, the formula bar, the +/// ribbon): only Escape, Enter or the expand button end that state. +pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool, rect: Option) { + if !collapsed { + d.refs.rect = rect; + } + d.refs.was_collapsed = collapsed; + d.refs.other_field = d.refs.collapsed.as_deref().is_some_and(|key| other_field_focused(ctx, key)); + // The box compares the grid selection with the one it last saw only while it is drawn: a + // hidden box would take a selection made meanwhile when it comes back. + if !std::mem::take(&mut d.refs.target_drawn) { + d.refs.target = None; + } + if !collapsed && ctx.text_edit_focused() && d.refs.target.as_deref().is_none_or(|key| !ctx.memory(|m| m.has_focus(ref_edit_id(key)))) { + d.refs.target = None; + } +} + +/// Makes the reference box `key` the one grid selections fill. The first pick remembers the +/// sheet and selection, which are put back when the dialog runs its command or closes. +pub(super) fn begin_pick(app: &SheetApp, d: &mut Dialog, key: &str) { + if let Some(doc) = app.session.active() + && let Some(sheet) = doc.wb.active() + { + let ranges: Vec = doc.selection.ranges.iter().map(|r| r.a1()).collect(); + let selection = Some((ranges.join(","), doc.selection.active.a1())); + match d.refs.home.as_mut() { + None => d.refs.home = Some(Home { uid: sheet.uid, selection }), + // Picking again after a failed OK: the selection to put back is the one now on the + // dialog's sheet. + Some(home) if home.selection.is_none() && home.uid.get() == sheet.uid.get() => { + home.selection = selection; + } + Some(_) => {} + } + } + d.refs.target = Some(key.to_string()); + d.refs.refocus = false; + d.refs.seen = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key)); + d.refs.seen_sheet = active_sheet_name(app); +} + +/// The name of the sheet shown in the active workbook. +fn active_sheet_name(app: &SheetApp) -> String { + app.session.active().and_then(|doc| doc.wb.active()).map(|sh| sh.name.clone()).unwrap_or_default() +} + +/// Stops picking and goes back to the dialog's own sheet, putting back the selection from before +/// picking began (once), unless that sheet was deleted: the selection is then left where it is. +/// The sheet itself is kept until the dialog closes. +/// +/// The commands go through [`SheetApp::run`], as the ribbon's do: showing the sheet first saves +/// a text-box draft left open on the sheet picked on, and the UI's after-command pass runs. +/// +/// Fails when the sheet could not be shown again (the draft could not be saved): the person +/// stays with their text, the selection waits for the next time the dialog lets go, and a +/// dialog about to run its command must not, since references without a sheet name would be +/// read against the sheet still shown. +pub(super) fn end_pick(app: &mut SheetApp, d: &mut Dialog) -> Result<(), String> { + d.refs.target = None; + d.refs.collapsed = None; + let Some(home) = d.refs.home.as_mut() else { return Ok(()) }; + let Some(sheet) = app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(|s| s.name.clone()) else { return Ok(()) }; + // After an OK that failed, the selection is already back and only the sheet is shown again, + // so references without a sheet name mean it. + if !active_sheet_name(app).eq_ignore_ascii_case(&sheet) { + app.run("sheet.activate", json!({"sheet": sheet}))?; + } + if let Some((range, active)) = home.selection.take() { + let _ = app.run("selection.set", json!({"range": range, "active": active})); + } + Ok(()) +} + +/// A reference box (RefEdit): a text field that the grid selection fills while it is the box +/// being picked into, and a button that collapses the dialog to the box or expands it again. +pub(super) fn ref_edit(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: RefLabel<'_>, width: f32) { + let lang = crate::i18n::current(ui.ctx()); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + let collapsed = d.refs.collapsed.as_deref() == Some(key); + ui.horizontal(|ui| { + let id = ref_edit_id(key); + let r = ui.add(egui::TextEdit::singleline(&mut s).id(id).desired_width(width - 30.0)); + let r = match label { + RefLabel::By(label) => r.labelled_by(label), + RefLabel::Named(name) => r.accessible_name(lang.tr(name)), + }; + // F4 cycles the anchors of the reference in the box, as in Excel's reference boxes. + if r.has_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F4)) { + s = cycle_anchors(&s); + } + if r.gained_focus() { + begin_pick(app, d, key); + } else if d.refs.target.as_deref() == Some(key) { + let sel = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key)); + let sheet = active_sheet_name(app); + if sheet != d.refs.seen_sheet { + // Another sheet was shown: its selection is where that sheet was left, not a pick. + d.refs.seen = sel; + d.refs.seen_sheet = sheet; + } else if sel != d.refs.seen { + // The person selected on the grid: the selection becomes the reference. + s = sel.clone(); + d.refs.seen = sel; + d.refs.refocus = true; + } + if d.refs.refocus && !ui.input(|i| i.pointer.any_down()) { + d.refs.refocus = false; + r.request_focus(); + } + } + let t = theme::Tokens::get(ui.ctx()); + let (icon, tip) = if collapsed { (Icon::Expand, "Expand dialog") } else { (Icon::Collapse, "Collapse dialog") }; + let b = crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0)); + let name = lang.tr(tip).to_string(); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); + if b.clicked() { + if collapsed { + d.refs.collapsed = None; + } else { + d.collapse_to(app, key); + } + ui.memory_mut(|m| m.request_focus(id)); + } + }); + if d.refs.target.as_deref() == Some(key) { + d.refs.target_drawn = true; + } + d.values.insert(key.into(), json!(s)); +} + +/// The anchors of one part of a reference (`$A$1`, `A`, `$3`): its column letters and row digits, +/// and whether each carries a `$`. +struct Anchors { + letters: String, + digits: String, + col_abs: bool, + row_abs: bool, +} + +/// Reads `part` as a cell (`A1`), a column (`A`) or a row (`3`) with its anchors: at most three +/// letters and seven digits, nothing else. A `$` alone before the digits of a row anchors the row. +fn anchors(part: &str) -> Option { + let (col_abs, rest) = match part.strip_prefix('$') { + Some(rest) => (true, rest), + None => (false, part), + }; + let letters: String = rest.chars().take_while(char::is_ascii_alphabetic).collect(); + let rest = rest.get(letters.len()..)?; + let (row_abs, rest) = match rest.strip_prefix('$') { + Some(rest) => (true, rest), + None => (false, rest), + }; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if !rest.get(digits.len()..)?.is_empty() || letters.len() > 3 || digits.len() > 7 || (letters.is_empty() && digits.is_empty()) { + return None; + } + if letters.is_empty() { + // `$3`: the one `$` belongs to the row; `$$3` is not a reference. + if col_abs && row_abs { + return None; + } + return Some(Anchors { letters, digits, col_abs: false, row_abs: col_abs || row_abs }); + } + Some(Anchors { letters, digits, col_abs, row_abs }) +} + +/// The areas of a reference list, split at the commas between them. A comma inside a quoted +/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes +/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the +/// quoted state, so a doubled one toggles it twice. +fn split_areas(reference: &str) -> Vec<&str> { + let mut areas = Vec::new(); + let (mut start, mut quoted) = (0, false); + for (i, c) in reference.char_indices() { + match c { + '\'' => quoted = !quoted, + ',' if !quoted => { + areas.push(reference.get(start..i).unwrap_or_default()); + start = i.saturating_add(1); + } + _ => {} + } + } + areas.push(reference.get(start..).unwrap_or_default()); + areas +} + +/// Excel's F4 in a reference box: `A1` → `$A$1` → `A$1` → `$A1` → `A1`, every part of every +/// area moving together from the first part's anchors (`A1:B2,D4` → `$A$1:$B$2,$D$4`), sheet +/// names kept; whole columns and rows have one anchor each (`A:A` → `$A:$A` → `A:A`). Text that +/// isn't a reference comes back as it is. +pub(crate) fn cycle_anchors(reference: &str) -> String { + let mut areas: Vec<(&str, Vec)> = Vec::new(); + for area in split_areas(reference) { + let area = area.trim(); + let (prefix, body) = match area.rsplit_once('!') { + Some((sheet, body)) => (area.get(..sheet.len() + 1).unwrap_or_default(), body), + None => ("", area), + }; + let parts: Vec<&str> = body.split(':').collect(); + if parts.len() > 2 { + return reference.to_string(); + } + let Some(parts) = parts.iter().map(|p| anchors(p)).collect::>>() else { return reference.to_string() }; + areas.push((prefix, parts)); + } + let Some(first) = areas.first().and_then(|(_, parts)| parts.first()) else { return reference.to_string() }; + let (col_abs, row_abs) = if first.letters.is_empty() { + (!first.row_abs, !first.row_abs) + } else if first.digits.is_empty() { + (!first.col_abs, !first.col_abs) + } else { + match (first.col_abs, first.row_abs) { + (false, false) => (true, true), + (true, true) => (false, true), + (false, true) => (true, false), + (true, false) => (false, false), + } + }; + let anchor = |on: bool, text: &str| if on && !text.is_empty() { format!("${text}") } else { text.to_string() }; + areas + .iter() + .map(|(prefix, parts)| { + let body: Vec = parts.iter().map(|a| format!("{}{}", anchor(col_abs, &a.letters), anchor(row_abs, &a.digits))).collect(); + format!("{prefix}{}", body.join(":")) + }) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::{cycle_anchors, split_areas}; + + #[test] + fn areas_split_only_at_commas_outside_quoted_sheet_names() { + assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]); + assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]); + assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]); + assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]); + assert_eq!(split_areas(""), [""]); + } + + #[test] + fn f4_keeps_a_comma_inside_a_quoted_sheet_name() { + assert_eq!(cycle_anchors("'Sales, West'!$A$1"), "'Sales, West'!A$1"); + assert_eq!(cycle_anchors("'O''Brien, Jr'!A1:B2,D4"), "'O''Brien, Jr'!$A$1:$B$2,$D$4"); + assert_eq!(cycle_anchors("B2:B4,D1"), "$B$2:$B$4,$D$1"); + } + + #[test] + fn f4_cycles_cells_ranges_columns_and_rows() { + assert_eq!(cycle_anchors("A1"), "$A$1"); + assert_eq!(cycle_anchors("$A$1"), "A$1"); + assert_eq!(cycle_anchors("A$1"), "$A1"); + assert_eq!(cycle_anchors("$A1"), "A1"); + assert_eq!(cycle_anchors("A1:B2"), "$A$1:$B$2"); + assert_eq!(cycle_anchors("$A$1:$B$2"), "A$1:B$2"); + // Every area follows the first part, and sheet names stay as typed. + assert_eq!(cycle_anchors("'O''Brien Rates'!A2:A4, D4"), "'O''Brien Rates'!$A$2:$A$4,$D$4"); + assert_eq!(cycle_anchors("Sheet1!$A$1:B2"), "Sheet1!A$1:B$2"); + assert_eq!(cycle_anchors("A:A"), "$A:$A"); + assert_eq!(cycle_anchors("$A:$A"), "A:A"); + assert_eq!(cycle_anchors("1:3"), "$1:$3"); + assert_eq!(cycle_anchors("$1:$3"), "1:3"); + } + + #[test] + fn f4_leaves_text_that_is_not_a_reference() { + for text in ["", "hello", "A1:hello", "ABCD1", "A12345678", "$$1", "$$A1", "A1:B2:C3", "Sheet1!", "A1,", "=A1"] { + assert_eq!(cycle_anchors(text), text); + } + } +} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 320f06371..70ba3fed5 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -9,6 +9,7 @@ use super::format_cells::format_cells; use super::insert_function::insert_function; use super::names::{name_manager, paste_name}; use super::paste::{paste_special, paste_special_shortcuts}; +use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, place_window, ref_edit, window_shown}; use super::spelling::spelling; use super::zoom::zoom; use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2}; @@ -157,8 +158,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "pasteSpecial" => 480.0, _ => 380.0, }; + let window_id = egui::Id::unique("dialog").with(&d.name); let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::unique("dialog").with(&d.name)) + .id(window_id) .collapsible(false) .resizable(false) .default_width(width) @@ -171,12 +173,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "advancedFilter" => window.default_height(0.0), _ => window, }; - window.show(ctx, |ui| { + let (window, collapsed) = place_window(app, ctx, &mut d, window, window_id); + let shown = window.show(ctx, |ui| { + if let Some(key) = &collapsed { + let title = d.title.clone(); + ref_edit(app, ui, &mut d, key, RefLabel::Named(&title), (width - 40.0).min(380.0)); + return; + } ui.set_min_width(width - 20.0); match d.name.as_str() { "formatCells" => format_cells(app, ui, &mut d, &mut confirm), - "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), - "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), + "dataValidation" => data_validation_form(app, ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(app, ui, &mut d, &mut confirm, &mut open), "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), "find" => find(app, ui, &mut d), "goTo" => go_to(app, ui, &mut d, &mut confirm), @@ -188,7 +196,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), - "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm, &mut open), "equation" => crate::equation::editor(app, ui, &mut d, &mut confirm, &mut open), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), @@ -365,9 +373,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { open = false; } } - _ => form(ui, &mut d, &mut confirm, &mut open), + _ => form(app, ui, &mut d, &mut confirm, &mut open), } }); + window_shown(ctx, &mut d, collapsed.is_some(), shown.map(|r| r.response.rect)); // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since // the native shell has no browser integration of its own. if let Some(open_url) = &app.services.open_url { @@ -398,6 +407,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); confirm = false; } + if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) { + // References without a sheet name would land on whichever sheet is shown now. + app.message = Some((d.title.clone(), SHEET_DELETED.into())); + confirm = false; + } + if confirm && let Err(e) = end_pick(app, &mut d) { + // References without a sheet name mean the sheet the dialog was opened on: when it can't + // be shown again (a text-box draft on the picked sheet could not be saved), the command + // would read them against the wrong sheet, so it doesn't run and the dialog stays. + app.message = Some((d.title.clone(), crate::clean_error(&e))); + confirm = false; + } if confirm && let Some(cmd) = d.command { let params = build_params(&d); match app.run(cmd, params) { @@ -424,6 +445,19 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } else if confirm { open = false; } + if !open + && !confirm + && let Err(e) = end_pick(app, &mut d) + { + // Cancelled or closed: back to the sheet and selection from before picking. A confirmed + // OK went back before its command ran, and stays where the command left things (Data + // Analysis shows the report sheet it added). When the dialog's sheet can't be shown + // again (a text-box draft on the picked sheet could not be saved), Cancel still closes + // the dialog, since there is no command left to protect: the reason is shown, and the + // draft and the selection stay where they are for the person to deal with. A script's + // `ui.dialog.cancel` gets the error instead and keeps the dialog, so it can act on it. + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } if app.ui_window() != home { // A command run from the dialog switched windows: whatever it opened shows in the new // window, and this dialog (or the one it returns to) stays in its own. @@ -470,8 +504,8 @@ pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) }); } -fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - form_fields(ui, d); +fn form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + form_fields(app, ui, d); ok_cancel(ui, confirm, open); } @@ -517,7 +551,7 @@ fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { } /// Draws a dialog's `fields`, editing its values. -pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn form_fields(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { // Field labels, choices and notes are English source strings, translated as they are drawn. let lang = crate::i18n::current(ui.ctx()); egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { @@ -546,6 +580,12 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } }); } + Field::Ref { key, label } => { + ui.vertical(|ui| { + let l = ui.label(lang.tr(label)); + ref_edit(app, ui, d, key, RefLabel::By(l.id), 320.0); + }); + } Field::Password { key, label } => { // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. ui.vertical(|ui| { diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4062f4a45..c7938ff4d 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -571,6 +571,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Zastavit", "New Macro" => "Nové makro", "Show" => "Zobrazit", + "Collapse dialog" => "Sbalit dialogové okno", + "Expand dialog" => "Rozbalit dialogové okno", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2a70f2f0a..97e6460e8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -487,6 +487,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Beenden", "New Macro" => "Neues Makro", "Show" => "Anzeigen", + "Collapse dialog" => "Dialogfeld reduzieren", + "Expand dialog" => "Dialogfeld erweitern", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 4fc7f5bac..7a29ee928 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -994,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Καταγραφή", "New Macro" => "Νέα μακροεντολή", "Show" => "Εμφάνιση", + "Collapse dialog" => "Σύμπτυξη παραθύρου διαλόγου", + "Expand dialog" => "Ανάπτυξη παραθύρου διαλόγου", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 92d22aa66..1db58e0fc 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -493,6 +493,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Detener", "New Macro" => "Nueva macro", "Show" => "Mostrar", + "Collapse dialog" => "Contraer diálogo", + "Expand dialog" => "Expandir diálogo", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index ff678c9da..a6a4c34a3 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -465,6 +465,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Arrêter", "New Macro" => "Nouvelle macro", "Show" => "Afficher", + "Collapse dialog" => "Réduire la boîte de dialogue", + "Expand dialog" => "Développer la boîte de dialogue", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e60f722a7..f72a4cdf1 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -997,6 +997,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Registrazione", "New Macro" => "Nuova macro", "Show" => "Mostra", + "Collapse dialog" => "Comprimi finestra di dialogo", + "Expand dialog" => "Espandi finestra di dialogo", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 3aea3f6ce..67cf83e87 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1002,6 +1002,8 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Tab Color", "シートの見出しの色"), ("Unhide…", "再表示…"), ("Unprotect Sheet…", "シート保護の解除…"), + ("Collapse dialog", "ダイアログを折りたたむ"), + ("Expand dialog", "ダイアログを展開する"), ]; /// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 194eb26ee..d725d97d1 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -461,6 +461,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "중지", "New Macro" => "새 매크로", "Show" => "표시", + "Collapse dialog" => "대화 상자 축소", + "Expand dialog" => "대화 상자 확장", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 485de1b00..d87befc4e 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -540,6 +540,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Stoppen", "New Macro" => "Nieuwe macro", "Show" => "Weergeven", + "Collapse dialog" => "Dialoogvenster samenvouwen", + "Expand dialog" => "Dialoogvenster uitvouwen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 2b487d1f6..1c78b6a17 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -988,6 +988,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Rejestrowanie", "New Macro" => "Nowe makro", "Show" => "Pokaż", + "Collapse dialog" => "Zwiń okno dialogowe", + "Expand dialog" => "Rozwiń okno dialogowe", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index e28dafcaa..6a126c055 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -994,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Gravando", "New Macro" => "Nova Macro", "Show" => "Mostrar", + "Collapse dialog" => "Recolher caixa de diálogo", + "Expand dialog" => "Expandir caixa de diálogo", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index ee35c4dca..b589bce0d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1050,6 +1050,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Înregistrare", "New Macro" => "Macrocomandă nouă", "Show" => "Afișare", + "Collapse dialog" => "Restrângere casetă de dialog", + "Expand dialog" => "Extindere casetă de dialog", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index eaefd5831..351734919 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -463,6 +463,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Остановить", "New Macro" => "Новый макрос", "Show" => "Показать", + "Collapse dialog" => "Свернуть диалоговое окно", + "Expand dialog" => "Развернуть диалоговое окно", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index f7b647090..2fc6749ee 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -460,6 +460,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Zaustavi", "New Macro" => "Novi makro", "Show" => "Prikaži", + "Collapse dialog" => "Skupi dijalog", + "Expand dialog" => "Proširi dijalog", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 7b66509c1..dd4e867f4 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -430,6 +430,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Durdur", "New Macro" => "Yeni Makro", "Show" => "Göster", + "Collapse dialog" => "İletişim kutusunu daralt", + "Expand dialog" => "İletişim kutusunu genişlet", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 6c2cbada2..3b16e6f7b 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -992,6 +992,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recording" => "Запис", "New Macro" => "Новий макрос", "Show" => "Показати", + "Collapse dialog" => "Згорнути діалогове вікно", + "Expand dialog" => "Розгорнути діалогове вікно", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index daadb1385..c67dca217 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -432,6 +432,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Dừng", "New Macro" => "Macro Mới", "Show" => "Hiện", + "Collapse dialog" => "Thu gọn hộp thoại", + "Expand dialog" => "Mở rộng hộp thoại", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index b73b116f1..42f2c1c6a 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -453,6 +453,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "停止", "New Macro" => "新建宏", "Show" => "显示", + "Collapse dialog" => "折叠对话框", + "Expand dialog" => "展开对话框", _ => return None, }) } diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs index 2f76148ed..95ee30501 100644 --- a/crates/ui-egui/src/ribbon/review.rs +++ b/crates/ui-egui/src/ribbon/review.rs @@ -65,7 +65,7 @@ pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) { ); }); sep(ui); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + let protected = app.active_sheet_protected(); let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); control_keytip(app, ui, &__k, "dialog:protectSheet"); if __k.clicked() { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 14f862ae0..f1240dcd6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -89,6 +89,7 @@ mod protected_cell; mod python_cell_edit; mod python_hidden; mod python_mode; +mod ref_edit; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/ref_edit.rs b/crates/ui-egui/tests/it/ref_edit.rs new file mode 100644 index 000000000..c711c82cd --- /dev/null +++ b/crates/ui-egui/tests/it/ref_edit.rs @@ -0,0 +1,1639 @@ +//! Reference boxes (RefEdit) in dialogs: selecting on the grid fills the box, and the collapse +//! button shrinks the dialog to the box while the person selects. Real pointer and keyboard input. + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::control::{Outcome, handle}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type H = egui_kittest::Harness<'static, SheetApp>; + +/// A control-channel request, as a script or an agent sends it. +fn control(h: &mut H, method: &str, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let r = match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + h.run_steps(3); + r +} + +fn harness(session: Session) -> H { + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + crate::common::app(session), + ); + h.run_steps(4); + h +} + +/// A workbook whose B5 is a formula of B2, with B2 selected. +fn workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B5", "input": "=B2*3+4"})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + s +} + +fn open_goal_seek(h: &mut H) { + h.state_mut().open_dialog("goalSeek", json!({})); + h.run_steps(4); +} + +fn field(h: &H, key: &str) -> String { + h.state().dialog.as_ref().and_then(|d| d.values.get(key).and_then(|v| v.as_str()).map(str::to_string)).unwrap_or_default() +} + +fn collapsed(h: &H) -> Option { + h.state().dialog.as_ref().and_then(|d| d.collapsed().map(str::to_string)) +} + +fn selection(h: &H) -> (usize, String) { + let d = h.state().session.active().unwrap(); + (d.wb.active_sheet(), d.selection.a1()) +} + +fn cell_pos(h: &H, cell: &str) -> egui::Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut H, pos: egui::Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +/// Presses on `from`, drags to `to` and releases, as selecting a range with the mouse. +fn drag(h: &mut H, from: &str, to: &str) { + let (a, b) = (cell_pos(h, from), cell_pos(h, to)); + h.input_mut().events.push(Event::PointerMoved(a)); + press(h, a, true); + h.run_steps(2); + h.input_mut().events.push(Event::PointerMoved((a + b.to_vec2()) / 2.0)); + h.run_steps(2); + h.input_mut().events.push(Event::PointerMoved(b)); + h.run_steps(2); + press(h, b, false); + h.run_steps(3); +} + +fn click_at(h: &mut H, pos: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + press(h, pos, true); + press(h, pos, false); + h.run_steps(3); +} + +fn key(h: &mut H, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + h.run_steps(3); +} + +fn type_text(h: &mut H, t: &str) { + h.input_mut().events.push(Event::Text(t.into())); + h.run_steps(3); +} + +/// The Goal Seek window (the collapsed box is also named after the dialog). +fn dialog_rect(h: &H) -> egui::Rect { + h.get_by_role_and_label(egui::accesskit::Role::Window, "Goal Seek").rect() +} + +/// The Goal Seek dialog's text boxes, top to bottom: Set cell, To value, By changing cell. +fn dialog_boxes(h: &H) -> Vec { + let title = dialog_rect(h); + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| title.contains(r.center())).collect() +} + +/// Clicks the collapse (or expand) button of the dialog's `n`th reference box. +fn click_button(h: &mut H, label: &str, n: usize) { + let rect = h.get_all_by_label(label).nth(n).map(|b| b.rect()).unwrap(); + click_at(h, rect.center()); +} + +#[test] +fn collapsing_shrinks_the_dialog_and_a_dragged_range_fills_the_box() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + assert_eq!(h.get_all_by_label("Collapse dialog").count(), 2, "Set cell and By changing cell are reference boxes"); + let full = dialog_rect(&h); + click_button(&mut h, "Collapse dialog", 1); + assert_eq!(collapsed(&h).as_deref(), Some("changing")); + assert!(h.query_by_label("To value:").is_none(), "only the reference box shows while collapsed"); + assert_eq!(h.get_all_by_label("Expand dialog").count(), 1); + assert_eq!(dialog_rect(&h).top(), full.top(), "the collapsed dialog keeps its place"); + + drag(&mut h, "C3", "D6"); + assert_eq!(field(&h, "changing"), "$C$3:$D$6"); + assert_eq!(field(&h, "set"), "B5", "only the box being picked into changes"); + + // The box has the keyboard back after the drag: typing replaces the reference. + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "B2"); + assert_eq!(field(&h, "changing"), "B2"); + + // Escape expands the dialog instead of closing it. + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_some()); + assert_eq!(collapsed(&h), None); + assert!(h.query_by_label("To value:").is_some()); + assert_eq!(dialog_rect(&h), full, "expanded, the dialog comes back where it was"); +} + +#[test] +fn enter_and_the_expand_button_restore_the_dialog_and_ok_runs_goal_seek() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(collapsed(&h), None, "Enter expands the dialog"); + assert!(h.state().dialog.is_some(), "Enter in the collapsed dialog does not press OK"); + + click_button(&mut h, "Collapse dialog", 0); + assert_eq!(collapsed(&h).as_deref(), Some("set")); + click_button(&mut h, "Expand dialog", 0); + assert_eq!(collapsed(&h), None); + + let to = dialog_boxes(&h)[1]; + click_at(&mut h, to.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "25"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let b2 = h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("B2").unwrap()); + assert!((b2.as_f64().unwrap() - 7.0).abs() < 1e-6, "{b2:?}"); + assert_eq!(selection(&h), (0, "B5".into()), "the selection from before picking comes back"); +} + +#[test] +fn a_range_on_another_sheet_carries_the_sheet_name_and_cancel_returns_home() { + let mut s = workbook(); + s.execute("home.insertSheet", json!({"name": "Rates Table", "before": 1})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // The sheet tabs' "Next sheet" arrow, below the grid at the left. + let grid = h.state().grid.rect.unwrap(); + click_at(&mut h, egui::pos2(grid.left() + 44.0, grid.bottom() + 16.0)); + assert_eq!(selection(&h).0, 1, "the next sheet is active while picking"); + drag(&mut h, "A2", "A4"); + assert_eq!(field(&h, "changing"), "'Rates Table'!$A$2:$A$4"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(collapsed(&h), None); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none(), "Escape in the expanded dialog cancels it"); + assert_eq!(selection(&h), (0, "B5".into()), "cancelling returns to the sheet and selection"); +} + +#[test] +fn switching_sheets_while_picking_keeps_a_typed_reference() { + let mut s = workbook(); + s.execute("home.insertSheet", json!({"name": "Rates Table", "before": 1})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "B2"); + assert_eq!(field(&h, "changing"), "B2"); + // The sheet tabs' "Next sheet" and "Previous sheet" arrows, below the grid at the left. + let grid = h.state().grid.rect.unwrap(); + click_at(&mut h, egui::pos2(grid.left() + 44.0, grid.bottom() + 16.0)); + assert_eq!(selection(&h).0, 1, "the next sheet is active while picking"); + assert_eq!(field(&h, "changing"), "B2", "changing sheets alone is not a pick"); + click_at(&mut h, egui::pos2(grid.left() + 20.0, grid.bottom() + 16.0)); + assert_eq!(selection(&h).0, 0, "back on the dialog's own sheet"); + assert_eq!(field(&h, "changing"), "B2", "coming back is not a pick either"); + click_at(&mut h, egui::pos2(grid.left() + 44.0, grid.bottom() + 16.0)); + drag(&mut h, "A2", "A4"); + assert_eq!(field(&h, "changing"), "'Rates Table'!$A$2:$A$4", "a selection on the other sheet still is"); +} + +#[test] +fn clicking_into_a_box_picks_into_it_and_another_field_stops_picking() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + let boxes = dialog_boxes(&h); + assert_eq!(boxes.len(), 3); + click_at(&mut h, boxes[2].center()); + let b2 = cell_pos(&h, "B2"); + click_at(&mut h, b2); + assert_eq!(field(&h, "changing"), "$B$2", "a click on the grid fills the box last clicked into"); + click_at(&mut h, boxes[1].center()); + let d9 = cell_pos(&h, "D9"); + click_at(&mut h, d9); + assert_eq!(field(&h, "changing"), "$B$2", "To value is not a reference box, so picking stopped"); + assert_eq!(field(&h, "set"), "B5"); +} + +#[test] +fn the_collapse_button_is_named_and_works_from_the_keyboard() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + h.get_all_by_label("Collapse dialog").nth(1).unwrap().focus(); + h.run_steps(2); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(collapsed(&h).as_deref(), Some("changing")); + h.get_by_label("Expand dialog").focus(); + h.run_steps(2); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(collapsed(&h), None); +} + +#[test] +fn data_analysis_picks_its_input_and_output_ranges() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + let buttons = h.get_all_by_label("Collapse dialog").count(); + assert_eq!(buttons, 2, "Input Range and Output Range are reference boxes"); + click_button(&mut h, "Collapse dialog", 0); + assert_eq!(collapsed(&h).as_deref(), Some("range")); + drag(&mut h, "B1", "B6"); + assert_eq!(field(&h, "range"), "$B$1:$B$6"); + key(&mut h, Key::Escape, Modifiers::NONE); + click_button(&mut h, "Collapse dialog", 1); + assert_eq!(collapsed(&h).as_deref(), Some("output")); + drag(&mut h, "D2", "D2"); + assert_eq!(field(&h, "output"), "$D$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(field(&h, "outputTo"), "range", "picking an output range chooses it"); +} + +#[test] +fn choosing_new_worksheet_ply_after_picking_an_output_range_sticks() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "D2", "D2"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(field(&h, "outputTo"), "range"); + + let ply = h.get_by_label("New Worksheet Ply:").rect(); + click_at(&mut h, ply.center()); + h.run_steps(3); + assert_eq!(field(&h, "outputTo"), "newSheet", "the radio choice is not overridden by the Output Range box"); + let labels = h.get_by_label("Labels in first row").rect(); + click_at(&mut h, labels.center()); + assert_eq!(field(&h, "outputTo"), "newSheet", "a checkbox click keeps the choice"); + + // Picking into Output Range ended with the radio choice: a later selection leaves it alone. + drag(&mut h, "F4", "F5"); + assert_eq!(field(&h, "output"), "$D$2"); + assert_eq!(field(&h, "outputTo"), "newSheet"); + + // Typing a range in the box chooses Output Range again. + let boxes: Vec = h + .get_all_by_role(egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().y > ply.top() - 40.0 && n.rect().center().y < ply.top()) + .map(|n| n.rect()) + .collect(); + assert_eq!(boxes.len(), 1, "the Output Range box, on the row above New Worksheet Ply"); + click_at(&mut h, boxes[0].center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "H1"); + assert_eq!(field(&h, "output"), "H1"); + assert_eq!(field(&h, "outputTo"), "range"); +} + +#[test] +fn solver_picks_its_objective_variables_and_constraint_cells() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + assert_eq!(h.get_all_by_label("Collapse dialog").count(), 4, "objective, variables and both sides of a constraint"); + click_button(&mut h, "Collapse dialog", 1); + assert_eq!(collapsed(&h).as_deref(), Some("variables")); + drag(&mut h, "B2", "B3"); + assert_eq!(field(&h, "variables"), "$B$2:$B$3"); + key(&mut h, Key::Enter, Modifiers::NONE); + click_button(&mut h, "Collapse dialog", 2); + drag(&mut h, "D4", "D4"); + assert_eq!(field(&h, "newLhs"), "$D$4"); + assert_eq!(field(&h, "objective"), "B5"); +} + +#[test] +fn a_sheet_inserted_while_picking_keeps_references_and_the_way_home() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // A sheet inserted before the dialog's own sheet (from the ribbon, which stays usable) moves + // that sheet to index 1 and becomes the active sheet at index 0. + h.state_mut().session.execute("home.insertSheet", json!({"name": "Inputs", "before": 0})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + drag(&mut h, "A2", "A3"); + assert_eq!(field(&h, "changing"), "Inputs!$A$2:$A$3", "a range on the new sheet carries its name"); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (1, "B5".into()), "cancelling returns to the dialog's own sheet, now at index 1"); +} + +#[test] +fn a_box_for_one_reference_takes_only_the_area_being_selected() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // Several areas, as Ctrl+click adds them: By changing cell takes one reference. + h.state_mut().session.execute("selection.set", json!({"range": "A1,C3:C4"})).unwrap(); + h.run_steps(3); + assert_eq!(field(&h, "changing"), "$C$3:$C$4", "only the area being selected, not a union"); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + + // Solver's variable cells take several areas, as in Excel. + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 1); + assert_eq!(collapsed(&h).as_deref(), Some("variables")); + h.state_mut().session.execute("selection.set", json!({"range": "A1,C3:C4"})).unwrap(); + h.run_steps(3); + assert_eq!(field(&h, "variables"), "$A$1,$C$3:$C$4"); + key(&mut h, Key::Escape, Modifiers::NONE); + click_button(&mut h, "Collapse dialog", 0); + assert_eq!(collapsed(&h).as_deref(), Some("objective")); + h.state_mut().session.execute("selection.set", json!({"range": "B5,D7"})).unwrap(); + h.run_steps(3); + assert_eq!(field(&h, "objective"), "$D$7", "the objective is one cell"); +} + +#[test] +fn renaming_the_dialogs_sheet_while_picking_keeps_the_way_home() { + let mut s = workbook(); + s.execute("home.insertSheet", json!({"name": "Rates", "before": 1})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // The dialog's own sheet is renamed from the ribbon while the box picks. + h.state_mut().session.execute("sheet.rename", json!({"sheet": 0, "name": "Model"})).unwrap(); + h.run_steps(3); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "$C$3", "the renamed sheet is still the dialog's own: no sheet name"); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + drag(&mut h, "A2", "A2"); + assert_eq!(field(&h, "changing"), "Rates!$A$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "B5".into()), "cancelling returns to the renamed sheet and its selection"); +} + +/// A workbook whose first sheet (the dialog's own, `Sheet1`) is followed by `Rates`. +fn two_sheets() -> Session { + let mut s = workbook(); + s.execute("home.insertSheet", json!({"name": "Rates", "before": 1})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + s +} + +fn sheet_names(h: &H) -> Vec { + h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect() +} + +#[test] +fn a_deleted_sheet_is_not_taken_for_one_inserted_in_its_place() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // The dialog's own sheet is deleted, and a new sheet then takes its place and its count. + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Fresh", "before": 0})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Fresh", "Rates"]); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "Fresh!$C$3", "the new sheet is not the dialog's own: picks carry its name"); + + // OK is refused rather than running unqualified references against another sheet. + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + let b2 = h.state().session.active().unwrap().wb.sheets[1].value(CellRef::parse("B2").unwrap()); + assert_eq!(b2.as_f64(), None, "Goal Seek changed nothing: {b2:?}"); + + // Cancel doesn't put the old selection onto the new sheet. + h.state_mut().message = None; + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "C3".into()), "the selection on the new sheet is left alone"); +} + +#[test] +fn a_deleted_sheet_qualifies_picks_and_cancel_stays_put() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Rates"]); + drag(&mut h, "A2", "A3"); + assert_eq!(field(&h, "changing"), "Rates!$A$2:$A$3"); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "A2:A3".into()), "nothing to go back to: the selection stays"); +} + +#[test] +fn data_analysis_refuses_ok_once_its_sheet_is_deleted() { + let mut h = harness(two_sheets()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + assert_eq!(sheet_names(&h), ["Rates"], "no report sheet was added"); +} + +#[test] +fn ok_stays_refused_after_a_failed_ok_once_the_sheet_is_deleted() { + let mut h = harness(two_sheets()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + drag(&mut h, "B1", "B6"); + assert_eq!(field(&h, "range"), "$B$1:$B$6"); + key(&mut h, Key::Escape, Modifiers::NONE); + // An output range the command refuses: OK fails and the dialog stays open. + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("outputTo".into(), json!("range")); + d.values.insert("output".into(), json!("not a reference")); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "the failed OK leaves the dialog open"); + assert!(h.state().message.is_some(), "and says why"); + h.state_mut().message = None; + + // The dialog's sheet is deleted; with a usable output range, OK must still be refused rather + // than run `$B$1:$B$6` against Rates. + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("output".into(), json!("Rates!$H$1")); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + let h1 = h.state().session.active().unwrap().wb.sheets[0].value(CellRef::parse("H1").unwrap()); + assert!(h1.is_empty(), "no report was written on Rates: {h1:?}"); +} + +#[test] +fn a_second_ok_after_a_failed_one_runs_on_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + drag(&mut h, "B1", "B6"); + key(&mut h, Key::Escape, Modifiers::NONE); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("outputTo".into(), json!("range")); + d.values.insert("output".into(), json!("not a reference")); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some()); + h.state_mut().message = None; + // The person looks at another sheet, then fixes the output range and presses OK again. + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("output".into(), json!("$H$1")); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); + let wb = &h.state().session.active().unwrap().wb; + assert!(!wb.sheets[0].value(CellRef::parse("H1").unwrap()).is_empty(), "the report is on the dialog's own sheet"); + assert!(wb.sheets[1].value(CellRef::parse("H1").unwrap()).is_empty(), "not on the sheet shown"); +} + +#[test] +fn solver_model_range_is_a_reference_box_until_its_area_is_hidden() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + h.get_by_label("Load/Save").click(); + h.run_steps(3); + assert_eq!(h.get_all_by_label("Collapse dialog").count(), 5, "Model Range is a reference box too"); + click_button(&mut h, "Collapse dialog", 4); + assert_eq!(collapsed(&h).as_deref(), Some("modelRange")); + drag(&mut h, "H1", "H3"); + assert_eq!(field(&h, "modelRange"), "$H$1:$H$3"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(collapsed(&h), None); + assert_eq!(field(&h, "modelRange"), "$H$1:$H$3", "expanding keeps the picked range"); + + // Hiding the Load/Save area ends picking into its box: a selection on the grid no longer + // changes a range that isn't shown. + h.get_by_label("Load/Save").click(); + h.run_steps(3); + assert!(h.query_by_label("Model Range:").is_none(), "the area is hidden"); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "modelRange"), "$H$1:$H$3"); +} + +#[test] +fn a_click_elsewhere_keeps_the_collapsed_box_picking() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // The Name Box takes the keyboard: the collapsed box still picks (expanded, another text + // field ends picking, since the person moved on to it). + let name_box = h.get_by_label("Name Box").rect(); + click_at(&mut h, name_box.center()); + assert_eq!(collapsed(&h).as_deref(), Some("changing"), "a click elsewhere doesn't expand the dialog"); + drag(&mut h, "C3", "D6"); + assert_eq!(field(&h, "changing"), "$C$3:$D$6", "the box still takes the selection"); + // The title bar and ribbon stay usable: Undo takes back the last edit (B5's formula) and + // puts the selection back where it was before that edit (A1, where `workbook()` entered it), + // which the box follows like any other selection; the dialog stays collapsed. + let undo = h.get_by_label("Undo").rect(); + click_at(&mut h, undo.center()); + assert_eq!(collapsed(&h).as_deref(), Some("changing")); + assert!(h.state().dialog.is_some()); + assert_eq!(selection(&h), (0, "A1".into())); + assert_eq!(field(&h, "changing"), "$A$1"); + drag(&mut h, "D2", "D2"); + assert_eq!(field(&h, "changing"), "$D$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(collapsed(&h), None, "Escape expands it"); + assert!(h.state().dialog.is_some()); +} + +#[test] +fn another_dialog_opened_while_picking_puts_the_selection_back() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "C3", "D6"); + assert_eq!(field(&h, "changing"), "$C$3:$D$6"); + assert_eq!(selection(&h), (0, "C3:D6".into())); + // A ribbon command opens another dialog while the box picks: the dialog it replaces is gone, + // and the sheet and selection from before picking come back, as they do when it is cancelled. + h.state_mut().open_dialog("goTo", json!({})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("goTo")); + assert_eq!(selection(&h), (0, "B5".into())); +} + +#[test] +fn protect_sheet_opened_while_picking_on_a_protected_sheet_acts_on_the_dialogs_sheet() { + let mut s = two_sheets(); + // Rates is protected with a password; Sheet1, the dialog's sheet, is not. + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("review.protectSheet", json!({"password": "rates"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + // Protect Sheet from the ribbon while the box picks on Rates: the dialog's sheet comes back + // first, and it is not protected, so Protect Sheet opens, not Unprotect Sheet for Rates. + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("protectSheet")); + assert_eq!(selection(&h), (0, "B5".into())); +} + +#[test] +fn protection_actions_refuse_a_deleted_dialog_home_without_unprotecting_the_picked_sheet() { + for action in ["protectSheet", "unprotectSheet"] { + let mut s = two_sheets(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.active().unwrap().is_protected()); + h.state_mut().open_dialog(action, json!({})); + h.run_steps(3); + let message = h.state().message.as_ref().map(|m| m.1.as_str()).unwrap_or(""); + assert!(message.contains("was deleted"), "{action}: {message}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("goalSeek")); + assert!(h.state().session.active().unwrap().wb.active().unwrap().is_protected(), "{action} must leave Rates protected"); + } +} + +#[test] +fn the_protect_sheet_label_follows_the_dialogs_sheet_while_picking_on_another() { + let mut s = two_sheets(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("review.protectSheet", json!({"password": "rates"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + // Rates is shown and protected, but Protect Sheet acts on Sheet1, the dialog's unprotected + // sheet: the Review tab, the Format menu and the tab menu offer Protect Sheet, the action it + // takes (all three read this). + assert!(!h.state().active_sheet_protected()); + // Without a dialog the label follows the sheet shown. + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + assert!(h.state().active_sheet_protected()); +} + +#[test] +fn arrow_keys_with_shift_select_a_range_into_the_collapsed_box() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // Shift+arrow first: a plain-key match would take it as a move and lose the extension. + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + assert_eq!(selection(&h), (0, "B5:B6".into())); + assert_eq!(field(&h, "changing"), "$B$5:$B$6"); + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + assert_eq!(field(&h, "changing"), "$B$5:$C$6"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(field(&h, "changing"), "$B$4", "an arrow without Shift moves the active cell"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(field(&h, "changing"), "$A$4"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + // Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data: up from B4 (empty) to B2 (1). + key(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(field(&h, "changing"), "$B$2"); + assert_eq!(collapsed(&h).as_deref(), Some("changing"), "the dialog stays collapsed"); + // The box keeps the keyboard: typing still replaces the reference. + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "B3"); + assert_eq!(field(&h, "changing"), "B3"); + // Expanded, the arrows edit the box (they move its caret), not the selection. + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(collapsed(&h), None); + let changing = dialog_boxes(&h)[2]; + click_at(&mut h, changing.center()); + let before = selection(&h); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + key(&mut h, Key::ArrowLeft, Modifiers::SHIFT); + assert_eq!(selection(&h), before); + assert_eq!(field(&h, "changing"), "B3"); +} + +#[test] +fn f4_cycles_the_reference_between_absolute_and_relative() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2"); + for expected in ["B$2", "$B2", "B2", "$B$2"] { + key(&mut h, Key::F4, Modifiers::NONE); + assert_eq!(field(&h, "changing"), expected); + } + assert_eq!(collapsed(&h).as_deref(), Some("changing"), "F4 doesn't expand the dialog"); + // Expanded, F4 still works in the focused box, on a typed range with a sheet name. + key(&mut h, Key::Escape, Modifiers::NONE); + let changing = dialog_boxes(&h)[2]; + click_at(&mut h, changing.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "'Rates Table'!A2:A4"); + key(&mut h, Key::F4, Modifiers::NONE); + assert_eq!(field(&h, "changing"), "'Rates Table'!$A$2:$A$4"); + // A field that is not a reference box leaves F4 alone. + let to = dialog_boxes(&h)[1]; + click_at(&mut h, to.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "25"); + key(&mut h, Key::F4, Modifiers::NONE); + assert_eq!(field(&h, "to"), "25"); + assert_eq!(field(&h, "changing"), "'Rates Table'!$A$2:$A$4"); +} + +/// `workbook()` with a second sheet whose name needs quoting (a space and an apostrophe), holding +/// 1 in A2 and 1..6 in A1:A6, and a formula on the first sheet that reads its A2. +fn quoted_sheet() -> Session { + let mut s = workbook(); + s.execute("home.insertSheet", json!({"name": "O'Brien Rates", "before": 1})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + for (i, v) in (1..=6).enumerate() { + s.execute("cell.set", json!({"cell": format!("A{}", i + 1), "input": v.to_string()})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + // 3 * A2 + 4 with A2 = 2 is 10; Goal Seek to 25 wants A2 = 7. + s.execute("cell.set", json!({"cell": "B5", "input": "='O''Brien Rates'!A2*3+4"})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + s +} + +/// Clicks the sheet tabs' "Next sheet" arrow, below the grid at the left. +fn next_sheet(h: &mut H) { + let grid = h.state().grid.rect.unwrap(); + click_at(h, egui::pos2(grid.left() + 44.0, grid.bottom() + 16.0)); +} + +#[test] +fn ok_after_a_cross_sheet_pick_runs_goal_seek_with_the_quoted_sheet_name() { + let mut h = harness(quoted_sheet()); + open_goal_seek(&mut h); + assert_eq!(field(&h, "set"), "B5"); + click_button(&mut h, "Collapse dialog", 1); + next_sheet(&mut h); + assert_eq!(selection(&h).0, 1); + drag(&mut h, "A2", "A2"); + assert_eq!(field(&h, "changing"), "'O''Brien Rates'!$A$2", "quoted as the command's parser reads it"); + key(&mut h, Key::Escape, Modifiers::NONE); + let to = dialog_boxes(&h)[1]; + click_at(&mut h, to.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "25"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); + let wb = &h.state().session.active().unwrap().wb; + let a2 = wb.sheets[1].value(CellRef::parse("A2").unwrap()); + assert!((a2.as_f64().unwrap() - 7.0).abs() < 1e-6, "the changing cell on the other sheet: {a2:?}"); + let b5 = wb.sheets[0].value(CellRef::parse("B5").unwrap()); + assert!((b5.as_f64().unwrap() - 25.0).abs() < 1e-6, "{b5:?}"); + assert_eq!(selection(&h), (0, "B5".into()), "back on the dialog's sheet and selection"); +} + +#[test] +fn data_analysis_runs_on_a_range_picked_on_another_sheet() { + let mut h = harness(quoted_sheet()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + next_sheet(&mut h); + drag(&mut h, "A1", "A6"); + assert_eq!(field(&h, "range"), "'O''Brien Rates'!$A$1:$A$6"); + key(&mut h, Key::Escape, Modifiers::NONE); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("outputTo".into(), json!("range")); + d.values.insert("output".into(), json!("$H$1")); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); + // The report is on the dialog's own sheet and read the other sheet's numbers: the mean of + // 1..6 is 21 / 6 = 3.5. + let wb = &h.state().session.active().unwrap().wb; + let report = &wb.sheets[0]; + let mean = (0..20).find_map(|row| { + let label = report.value(CellRef::new(row, 7)); + (label.as_text().map(str::trim) == Some("Mean")).then(|| report.value(CellRef::new(row, 8))) + }); + assert_eq!(mean.and_then(|v| v.as_f64()), Some(3.5), "{:?}", (0..20).map(|r| report.value(CellRef::new(r, 7))).collect::>()); + assert!(wb.sheets[1].value(CellRef::parse("H1").unwrap()).is_empty(), "nothing was written on the picked sheet"); +} + +#[test] +fn enter_while_collapsed_only_expands_the_dialog() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + let to = dialog_boxes(&h)[1]; + click_at(&mut h, to.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "25"); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2"); + assert!(h.query_by_label(" OK ").is_none(), "the collapsed dialog shows no OK button"); + let b2 = |h: &H| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("B2").unwrap()).as_f64(); + let undo_steps = |h: &H| h.state().session.active().unwrap().undo.len(); + let steps = undo_steps(&h); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(collapsed(&h), None, "Enter expands the dialog"); + assert!(h.state().dialog.is_some(), "and doesn't close it"); + assert_eq!(b2(&h), Some(1.0), "Goal Seek did not run"); + assert_eq!(undo_steps(&h), steps, "no undo step was added"); + // OK is what runs it. + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert!((b2(&h).unwrap() - 7.0).abs() < 1e-6); + assert_eq!(undo_steps(&h), steps + 1); +} + +#[test] +fn a_successful_analysis_to_a_new_sheet_leaves_that_sheet_active() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + drag(&mut h, "B1", "B6"); + assert_eq!(field(&h, "range"), "$B$1:$B$6"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(field(&h, "outputTo"), "newSheet", "New Worksheet Ply is the default output"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); + // The command added the report sheet and showed it; closing the dialog must not go back to + // the sheet it was opened on, as Excel shows the new sheet. + assert_eq!(sheet_names(&h).len(), 2); + assert_eq!(selection(&h).0, 1, "the report sheet stays active"); +} + +#[test] +fn a_scripted_cancel_or_ok_goes_back_to_the_dialogs_sheet_like_the_buttons() { + // Cancel from the control channel puts the sheet and selection back. + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + next_sheet(&mut h); + drag(&mut h, "A2", "A2"); + assert_eq!(field(&h, "changing"), "Rates!$A$2"); + assert_eq!(selection(&h), (1, "A2".into())); + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "B5".into()), "a scripted cancel returns home as the button does"); + + // OK from the control channel runs the command on the dialog's sheet: `$H$1` means the sheet + // the dialog was opened on, not the sheet the box picked on. + let mut h = harness(quoted_sheet()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + next_sheet(&mut h); + drag(&mut h, "A1", "A6"); + assert_eq!(field(&h, "range"), "'O''Brien Rates'!$A$1:$A$6"); + control(&mut h, "ui.dialog.set", json!({"field": "outputTo", "value": "range"})); + control(&mut h, "ui.dialog.set", json!({"field": "output", "value": "$H$1"})); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert!(h.state().dialog.is_none()); + let wb = &h.state().session.active().unwrap().wb; + assert!(!wb.sheets[0].value(CellRef::parse("H1").unwrap()).is_empty(), "the report is on the dialog's own sheet"); + assert!(wb.sheets[1].value(CellRef::parse("H1").unwrap()).is_empty(), "not on the sheet shown while picking"); + assert_eq!(selection(&h).0, 0); + + // A deleted dialog sheet refuses a scripted OK as it refuses the button (Goal Seek presses + // its own OK, so `ui.dialog.confirm` refuses it by design; Data Analysis has a command). + let mut h = harness(two_sheets()); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(r["error"].as_str().unwrap_or("").contains("was deleted"), "{r}"); + assert!(h.state().dialog.is_some(), "the dialog stays open"); +} + +#[test] +fn a_text_box_draft_is_saved_before_a_scripted_cancel_leaves_the_picked_sheet() { + let mut s = two_sheets(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + let id = + s.execute("insert.textBox", json!({"at": "B2", "width": 240, "height": 120, "text": "Rate note"})).unwrap()["shape"].as_u64().unwrap() as u32; + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + next_sheet(&mut h); + // The person starts editing the text box on the picked sheet (what its double-click does) + // and types a new note. + assert!(h.state_mut().begin_text_box_edit(id), "the text box is being edited"); + h.run_steps(3); + assert!(h.state().text_box_editor.is_some()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "Picked note"); + assert_eq!(h.state().text_box_editor.as_ref().map(|ed| ed.text.as_str()), Some("Picked note")); + let shape_text = + |h: &H, sheet: usize| h.state().session.active().unwrap().wb.sheets[sheet].shapes.iter().find(|s| s.id == id).unwrap().text.clone(); + assert_eq!(shape_text(&h, 1), "Rate note", "not saved yet"); + // A scripted cancel leaves the sheet: the draft is saved first, as the ribbon's sheet switch + // saves it, instead of being stranded on a sheet that is no longer shown. + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert!(h.state().text_box_editor.is_none(), "the draft was committed: {:?}", h.state().text_box_editor); + assert_eq!(shape_text(&h, 1), "Picked note"); + assert_eq!(selection(&h), (0, "B5".into())); + assert!(h.state().message.is_none(), "{:?}", h.state().message); +} + +/// `quoted_sheet()` with a text box on its second sheet and a third sheet, Scratch, so that a +/// draft of the text box can be left where it cannot be saved (see [`strand_a_draft`]). +fn draft_workbook() -> (Session, u32) { + let mut s = quoted_sheet(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + let id = + s.execute("insert.textBox", json!({"at": "D2", "width": 200, "height": 100, "text": "Rate note"})).unwrap()["shape"].as_u64().unwrap() as u32; + s.execute("home.insertSheet", json!({"name": "Scratch", "before": 2})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + (s, id) +} + +/// With the second sheet shown: starts editing its text box `id`, types a note, then has a +/// script show Scratch straight through the engine. The draft's worksheet is no longer the +/// active one, so it can't be saved, and showing the dialog's sheet again (which saves drafts +/// first) is refused. +fn strand_a_draft(h: &mut H, id: u32) { + assert!(h.state_mut().begin_text_box_edit(id)); + h.run_steps(3); + key(h, Key::A, Modifiers::COMMAND); + type_text(h, "Picked note"); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 2})).unwrap(); + h.run_steps(3); + assert_eq!(selection(h).0, 2); +} + +fn draft_text(h: &H) -> Option { + h.state().text_box_editor.as_ref().map(|ed| ed.text.clone()) +} + +#[test] +fn a_draft_that_cannot_be_saved_stops_a_confirm_after_a_cross_sheet_pick() { + let (s, id) = draft_workbook(); + let mut h = harness(s); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 0); + next_sheet(&mut h); + drag(&mut h, "A1", "A6"); + assert_eq!(field(&h, "range"), "'O''Brien Rates'!$A$1:$A$6"); + key(&mut h, Key::Escape, Modifiers::NONE); + control(&mut h, "ui.dialog.set", json!({"field": "outputTo", "value": "range"})); + control(&mut h, "ui.dialog.set", json!({"field": "output", "value": "$H$1"})); + strand_a_draft(&mut h, id); + let h1 = |h: &H, sheet: usize| h.state().session.active().unwrap().wb.sheets[sheet].value(CellRef::parse("H1").unwrap()); + + // The control channel's confirm: the dialog's sheet can't be shown again, so the command + // doesn't run anywhere (`$H$1` would have meant the sheet shown, Scratch). + let r = control(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + assert!(h.state().text_box_editor.is_some(), "the draft is kept"); + for sheet in 0..3 { + assert!(h1(&h, sheet).is_empty(), "no report on sheet {sheet}: {:?}", h1(&h, sheet)); + } + assert_eq!(selection(&h).0, 2, "still on the sheet shown"); + + // The window's OK: the same, with the reason shown. + h.state_mut().message = None; + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + assert!(h.state().message.is_some(), "the reason is shown"); + for sheet in 0..3 { + assert!(h1(&h, sheet).is_empty(), "no report on sheet {sheet}: {:?}", h1(&h, sheet)); + } + assert_eq!(h.state().text_box_editor.as_ref().map(|ed| ed.text.as_str()), Some("Picked note"), "the person's text is kept"); +} + +#[test] +fn a_scripted_cancel_with_an_unsavable_draft_keeps_the_dialog_until_it_can_go_home() { + let (s, id) = draft_workbook(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + next_sheet(&mut h); + drag(&mut h, "A2", "A2"); + assert_eq!(field(&h, "changing"), "'O''Brien Rates'!$A$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + strand_a_draft(&mut h, id); + // The dialog's sheet can't be shown again: the script gets the reason, the dialog stays. + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(h.state().dialog.is_some(), "the dialog stays open"); + assert_eq!(draft_text(&h).as_deref(), Some("Picked note"), "the draft is kept"); + assert_eq!(selection(&h).0, 2); + // Back on the draft's sheet, cancelling saves the draft and goes home. + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert!(h.state().dialog.is_none()); + assert_eq!(draft_text(&h), None, "the draft was saved"); + let text = h.state().session.active().unwrap().wb.sheets[1].shapes.iter().find(|s| s.id == id).unwrap().text.clone(); + assert_eq!(text, "Picked note"); + assert_eq!(selection(&h), (0, "B5".into())); +} + +#[test] +fn the_windows_cancel_with_an_unsavable_draft_closes_the_dialog_and_says_why() { + let (s, id) = draft_workbook(); + let mut h = harness(s); + h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"})); + h.run_steps(4); + let title = h.state().dialog.as_ref().unwrap().title.clone(); + click_button(&mut h, "Collapse dialog", 0); + next_sheet(&mut h); + drag(&mut h, "A1", "A6"); + key(&mut h, Key::Escape, Modifiers::NONE); + strand_a_draft(&mut h, id); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "Cancel closes the dialog whatever the draft's state"); + let message = h.state().message.clone().unwrap_or_default(); + assert_eq!(message.0, title, "the dialog says why it could not go home: {message:?}"); + assert_eq!(draft_text(&h).as_deref(), Some("Picked note"), "the draft is kept"); + assert_eq!(selection(&h).0, 2, "the selection stays where it is"); + let wb = &h.state().session.active().unwrap().wb; + for sheet in 0..3 { + assert!(wb.sheets[sheet].value(CellRef::parse("H1").unwrap()).is_empty(), "nothing ran on sheet {sheet}"); + } +} + +#[test] +fn goal_seeks_cancel_with_an_unsavable_draft_closes_without_running() { + let (s, id) = draft_workbook(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + next_sheet(&mut h); + drag(&mut h, "A2", "A2"); + key(&mut h, Key::Escape, Modifiers::NONE); + // A To value OK would use, so a run by mistake would show on the other sheet's A2. + let to = dialog_boxes(&h)[1]; + click_at(&mut h, to.center()); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "25"); + strand_a_draft(&mut h, id); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "Cancel is not a confirmation: the dialog closes"); + let a2 = h.state().session.active().unwrap().wb.sheets[1].value(CellRef::parse("A2").unwrap()); + assert_eq!(a2.as_f64(), Some(2.0), "Goal Seek did not run: {a2:?}"); + assert_eq!(draft_text(&h).as_deref(), Some("Picked note"), "the draft is kept"); + let message = h.state().message.clone().unwrap_or_default(); + assert_eq!(message.0, "Goal Seek", "the reason is shown: {message:?}"); + assert_eq!(selection(&h).0, 2); +} + +#[test] +fn a_sheet_deleted_and_another_inserted_in_one_batch_is_not_taken_for_a_rename() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // One script batch, between two frames: the dialog's sheet goes and another takes its place, + // which leaves the name list the same length with one entry changed, as a rename would. + let s = &mut h.state_mut().session; + s.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Fresh", "before": 0})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Fresh", "Rates"]); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "Fresh!$C$3", "the new sheet is not the dialog's own: picks carry its name"); + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "OK is refused"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + h.state_mut().message = None; + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "C3".into()), "cancelling does not put the old selection onto the new sheet"); +} + +#[test] +fn undoing_and_redoing_the_dialogs_sheets_rename_still_follow_it() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("sheet.rename", json!({"sheet": 0, "name": "Model"})).unwrap(); + h.run_steps(3); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "$C$3", "renamed, still the dialog's own sheet"); + // Undo brings the old name back; redo the new one: both are recorded steps that rename. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h)[0], "Sheet1"); + drag(&mut h, "C4", "C4"); + assert_eq!(field(&h, "changing"), "$C$4"); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h)[0], "Model"); + drag(&mut h, "C5", "C5"); + assert_eq!(field(&h, "changing"), "$C$5"); + key(&mut h, Key::Escape, Modifiers::NONE); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(&h), (0, "B5".into()), "cancelling returns to the renamed sheet and its selection"); +} + +/// Types 25 into To value and presses OK, which runs Goal Seek on the dialog's sheet: B5 = B2 * 3 +/// + 4 = 25 wants B2 = 7 there. +fn ok_to_25(h: &mut H) { + let to = dialog_boxes(h)[1]; + click_at(h, to.center()); + key(h, Key::A, Modifiers::COMMAND); + type_text(h, "25"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); +} + +/// The sheet shown, at `index`, carries the dialog's sheet's name but is another sheet: picks on +/// it carry its name, OK is refused with the "was deleted" message and runs nothing, and Cancel +/// leaves the selection on it where it is. +fn replacement_is_refused(h: &mut H, index: usize) { + drag(h, "C3", "C3"); + assert_eq!(field(h, "changing"), "Sheet1!$C$3", "the new sheet is not the dialog's own: picks carry its name"); + key(h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "OK is refused"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + let b2 = h.state().session.active().unwrap().wb.sheets[index].value(CellRef::parse("B2").unwrap()); + assert!(b2.is_empty(), "Goal Seek changed nothing on the new sheet: {b2:?}"); + h.state_mut().message = None; + key(h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + assert_eq!(selection(h), (index, "C3".into()), "cancelling does not put the old selection onto the new sheet"); +} + +#[test] +fn a_sheet_deleted_and_recreated_under_its_own_name_is_not_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // One script batch, between two frames: the dialog's sheet goes and a new sheet is given its + // name, so a sheet called Sheet1 is there as before. + let s = &mut h.state_mut().session; + s.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet1"})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Rates", "Sheet1"]); + assert_eq!(selection(&h).0, 1, "the new sheet is shown"); + replacement_is_refused(&mut h, 1); +} + +#[test] +fn a_scripted_cancel_says_when_the_dialogs_sheet_was_deleted_even_if_its_name_was_reused() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + let s = &mut h.state_mut().session; + s.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet1", "before": 0})).unwrap(); + h.run_steps(3); + // A sheet called Sheet1 is shown, but it is not the dialog's: cancel says so, so a script + // does not go on to run data.goalSeek against it. + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(r["result"], json!({"sheetDeleted": true}), "{r}"); + assert!(h.state().dialog.is_none()); + // A dialog whose sheet is still there replies null, as before. + open_goal_seek(&mut h); + let r = control(&mut h, "ui.dialog.cancel", json!({})); + assert_eq!(r["result"], json!(null), "{r}"); +} + +#[test] +fn a_sheet_recreated_under_its_own_name_at_its_old_index_is_not_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // The new sheet takes the old one's name and index: the sheet list reads exactly as before. + let s = &mut h.state_mut().session; + s.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet1", "before": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Rates"]); + assert_eq!(selection(&h).0, 0); + replacement_is_refused(&mut h, 0); +} + +#[test] +fn a_sheet_recreated_under_its_own_name_among_inserted_and_moved_sheets_is_not_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + let s = &mut h.state_mut().session; + s.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Extra", "before": 0})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet1", "before": 1})).unwrap(); + s.execute("sheet.move", json!({"sheet": 2, "to": 0})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 2})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Rates", "Extra", "Sheet1"]); + replacement_is_refused(&mut h, 2); +} + +#[test] +fn undoing_the_deletion_of_the_dialogs_sheet_brings_it_back_and_redoing_takes_it_again() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + drag(&mut h, "A2", "A2"); + assert_eq!(field(&h, "changing"), "Rates!$A$2", "deleted: picks carry the sheet name"); + // Undo brings the very sheet back: it is the dialog's own again. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Rates"]); + assert_eq!(selection(&h).0, 0); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "$C$3", "restored by undo, the sheet is the dialog's own"); + // Redo deletes it again: OK is refused. + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Rates"]); + drag(&mut h, "A3", "A3"); + assert_eq!(field(&h, "changing"), "Rates!$A$3"); + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "OK is refused"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + h.state_mut().message = None; + // Undone once more, OK works and resolves on the restored sheet. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Rates"]); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + ok_to_25(&mut h); + // B5 = B2 * 3 + 4 = 25 wants B2 = 7. + let b2 = h.state().session.active().unwrap().wb.sheets[0].value(CellRef::parse("B2").unwrap()); + assert!((b2.as_f64().unwrap() - 7.0).abs() < 1e-6, "Goal Seek ran on the restored sheet: {b2:?}"); + assert_eq!(selection(&h), (0, "B5".into()), "back on the dialog's sheet and selection"); +} + +#[test] +fn a_new_sheet_given_the_dialogs_sheets_old_name_is_not_taken_for_it() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut().session.execute("sheet.rename", json!({"sheet": 0, "name": "Model"})).unwrap(); + h.run_steps(3); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Sheet1", "before": 2})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Model", "Rates", "Sheet1"]); + assert_eq!(selection(&h).0, 2, "the new sheet is shown"); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "Sheet1!$C$3", "the new Sheet1 is not the dialog's sheet"); + // The dialog's sheet is Model: picks there are unqualified, and OK resolves against it. + h.state_mut().session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2"); + key(&mut h, Key::Escape, Modifiers::NONE); + ok_to_25(&mut h); + let wb = &h.state().session.active().unwrap().wb; + // B5 = B2 * 3 + 4 = 25 wants B2 = 7, on Model. + let b2 = wb.sheets[0].value(CellRef::parse("B2").unwrap()); + assert!((b2.as_f64().unwrap() - 7.0).abs() < 1e-6, "{b2:?}"); + assert!(wb.sheets[2].value(CellRef::parse("B2").unwrap()).is_empty(), "the new Sheet1 is untouched"); + assert_eq!(selection(&h), (0, "B5".into())); +} + +/// `two_sheets`, saved as xlsx and opened again, so that its sheets carry the file's sheet ids +/// (which a copy of a sheet keeps). +fn two_sheets_from_file() -> Session { + let mut s = two_sheets(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "Book.xlsx", "base64": saved["base64"]})).unwrap(); + assert!(s.active().unwrap().wb.sheets.iter().all(|sh| sh.file_id != 0), "the sheets came from a file"); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + s +} + +#[test] +fn one_undo_step_that_deletes_and_recreates_the_dialogs_sheet_is_not_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // One recorded step (a macro's undo group, say): the sheet list reads as before, and only the + // identity of the new Sheet1 tells it from the dialog's sheet. + h.state_mut() + .session + .grouped("Replace Sheet1", |s| { + s.execute("home.deleteSheet", json!({"sheet": 0}))?; + s.execute("home.insertSheet", json!({"name": "Sheet1", "before": 0})) + }) + .unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Rates"]); + let undo_len = h.state().session.active().unwrap().undo.len(); + assert_eq!(undo_len, two_sheets().active().unwrap().undo.len() + 1, "one undo step"); + replacement_is_refused(&mut h, 0); +} + +#[test] +fn one_undo_step_that_replaces_an_empty_sheet_is_not_taken_for_a_rename() { + let mut s = two_sheets(); + // The dialog's sheet is Rates, which has no cells, as a sheet inserted in its place has none. + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let mut h = harness(s); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + h.state_mut() + .session + .grouped("Replace Rates", |s| { + s.execute("home.deleteSheet", json!({"sheet": 1}))?; + s.execute("home.insertSheet", json!({"name": "Fresh", "before": 1})) + }) + .unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Fresh"], "same count, one name changed at the sheet's index, no cells either side"); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "Fresh!$C$3", "another sheet: not a rename"); + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "OK is refused"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); +} + +#[test] +fn one_undo_step_that_renames_the_dialogs_sheet_and_reuses_its_old_name_follows_the_rename() { + let mut h = harness(two_sheets()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + // One recorded step: the dialog's sheet becomes Model and a new sheet takes its old name. + // The sheet's identity finds it under the new name. + h.state_mut() + .session + .grouped("Rename and insert", |s| { + s.execute("sheet.rename", json!({"sheet": 0, "name": "Model"}))?; + s.execute("home.insertSheet", json!({"name": "Sheet1", "before": 2})) + }) + .unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Model", "Rates", "Sheet1"]); + assert_eq!(selection(&h).0, 2, "the new sheet is shown"); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "Sheet1!$C$3", "the new Sheet1 is not the dialog's sheet"); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2", "Model is the dialog's sheet"); + key(&mut h, Key::Escape, Modifiers::NONE); + ok_to_25(&mut h); + let wb = &h.state().session.active().unwrap().wb; + let b2 = wb.sheets[0].value(CellRef::parse("B2").unwrap()); + assert!((b2.as_f64().unwrap() - 7.0).abs() < 1e-6, "Goal Seek ran on Model: {b2:?}"); + assert!(wb.sheets[2].value(CellRef::parse("B2").unwrap()).is_empty(), "the new Sheet1 is untouched"); + assert_eq!(selection(&h), (0, "B5".into())); +} + +#[test] +fn solver_options_end_picking_into_a_box_the_view_hides() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B3"); + assert_eq!(field(&h, "variables"), "$B$2:$B$3"); + key(&mut h, Key::Enter, Modifiers::NONE); + // The Options view hides every reference box; the dialog is not modal, so the grid still + // takes a selection meanwhile. + h.get_by_label("Options").click(); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Collapse dialog").count(), 0, "no reference box is shown"); + drag(&mut h, "C3", "C3"); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert_eq!(field(&h, "variables"), "$B$2:$B$3", "the selection made while the box was hidden is not a pick"); + // Picking ended when the box went: a later selection does not go into it either. + drag(&mut h, "D4", "D4"); + assert_eq!(field(&h, "variables"), "$B$2:$B$3"); +} + +#[test] +fn an_int_constraint_ends_picking_into_the_hidden_constraint_value_box() { + let mut h = harness(workbook()); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + click_button(&mut h, "Collapse dialog", 3); + assert_eq!(collapsed(&h).as_deref(), Some("newRhs")); + drag(&mut h, "D4", "D4"); + assert_eq!(field(&h, "newRhs"), "$D$4"); + key(&mut h, Key::Enter, Modifiers::NONE); + // int and bin take no constraint value: the box goes while it is being picked into. + h.get_by_label("Constraint Operator").click(); + h.run_steps(3); + h.get_by_label("int").click(); + h.run_steps(3); + assert!(h.query_by_label("Constraint Value").is_none(), "the box is hidden"); + drag(&mut h, "E5", "E5"); + assert_eq!(field(&h, "newRhs"), "$D$4", "nothing lands in the hidden box"); + // Shown again with another operator, the box keeps its value and is not picked into. + h.get_by_label("Constraint Operator").click(); + h.run_steps(3); + h.get_by_label("<=").click(); + h.run_steps(3); + assert_eq!(field(&h, "newRhs"), "$D$4", "the selection made while hidden is not a pick"); + drag(&mut h, "F6", "F6"); + assert_eq!(field(&h, "newRhs"), "$D$4"); +} + +#[test] +fn the_name_box_keeps_its_arrow_keys_and_escape_while_a_dialog_is_collapsed() { + let mut h = harness(workbook()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "$C$3"); + // The Name Box takes the keyboard: its arrow keys edit its text, not the grid selection. + let name_box = egui::Id::unique("gridcraft.name_box"); + let center = h.get_by_label("Name Box").rect().center(); + click_at(&mut h, center); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(name_box), "the Name Box has the keyboard"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(name_box), "the Name Box keeps the keyboard"); + assert_eq!(selection(&h), (0, "C3".into()), "the grid selection is unchanged"); + assert_eq!(field(&h, "changing"), "$C$3", "the picked reference is unchanged"); + assert_eq!(collapsed(&h).as_deref(), Some("changing")); + // Escape leaves the Name Box, as it did before (the grid takes the keyboard back); the dialog + // stays collapsed. + key(&mut h, Key::Escape, Modifiers::NONE); + assert_ne!(h.ctx.memory(|m| m.focused()), Some(name_box), "Escape left the Name Box"); + assert_eq!(collapsed(&h).as_deref(), Some("changing"), "that Escape was the Name Box's"); + assert!(h.state().dialog.is_some()); + // With no other field on the keyboard, the collapsed picker takes the keys again. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(field(&h, "changing"), "$C$4"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(collapsed(&h), None, "Escape expands the dialog"); +} + +#[test] +fn a_copy_of_the_dialogs_sheet_is_not_the_dialogs_sheet_once_the_original_is_deleted() { + let mut h = harness(two_sheets_from_file()); + open_goal_seek(&mut h); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "C3", "C3"); + assert_eq!(field(&h, "changing"), "$C$3"); + // A copy of the dialog's sheet (the same cells, and the file's sheet id) becomes active. + h.state_mut().session.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Sheet1 (2)", "Rates"]); + assert_eq!(selection(&h).0, 1, "the copy is shown"); + drag(&mut h, "C4", "C4"); + assert_eq!(field(&h, "changing"), "'Sheet1 (2)'!$C$4", "the copy is not the dialog's sheet"); + // The original is deleted: the copy is the only sheet of its id, and still not the dialog's. + h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1 (2)", "Rates"]); + drag(&mut h, "C5", "C5"); + assert_eq!(field(&h, "changing"), "'Sheet1 (2)'!$C$5", "the copy is not the dialog's sheet once the original is gone"); + key(&mut h, Key::Escape, Modifiers::NONE); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "OK is refused"); + let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default(); + assert!(message.contains("was deleted"), "{message}"); + let b2 = h.state().session.active().unwrap().wb.sheets[0].value(CellRef::parse("B2").unwrap()); + assert_eq!(b2.as_f64(), Some(1.0), "Goal Seek did not run on the copy: {b2:?}"); + h.state_mut().message = None; + // Undo brings the original back: it is the dialog's sheet again, and OK runs there. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_names(&h), ["Sheet1", "Sheet1 (2)", "Rates"]); + click_button(&mut h, "Collapse dialog", 1); + drag(&mut h, "B2", "B2"); + assert_eq!(field(&h, "changing"), "$B$2", "restored by undo, the original is the dialog's own"); + key(&mut h, Key::Escape, Modifiers::NONE); + ok_to_25(&mut h); + let wb = &h.state().session.active().unwrap().wb; + let b2 = wb.sheets[0].value(CellRef::parse("B2").unwrap()); + assert!((b2.as_f64().unwrap() - 7.0).abs() < 1e-6, "Goal Seek ran on the original: {b2:?}"); + assert_eq!(wb.sheets[1].value(CellRef::parse("B2").unwrap()).as_f64(), Some(1.0), "the copy is untouched"); + assert_eq!(selection(&h), (0, "B5".into())); +} + +/// `two_sheets` with a Solver model solved and saved on each sheet: Sheet1's B5 = B2 * 3 + 4 to +/// 25 by changing B2, and Rates' A2 = A1 * 2 to 10 by changing A1. +fn two_solver_models() -> Session { + let mut s = two_sheets(); + s.execute("data.solver", json!({"objective": "B5", "goal": "value", "value": 25, "variables": "B2"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "=A1*2"})).unwrap(); + s.execute("data.solver", json!({"objective": "A2", "goal": "value", "value": 10, "variables": "A1"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + s +} + +/// How many hidden `solver_*` names hold the model of sheet `sheet`. +fn model_names(h: &H, sheet: usize) -> usize { + h.state().session.active().unwrap().wb.names.iter().filter(|n| n.scope == Some(sheet) && n.name.starts_with("solver_")).count() +} + +/// Picks `cell` (not A1, which is that sheet's own selection and so no pick) on Rates into +/// Solver's constraint cell box, then expands the dialog, leaving Rates shown: the state after a +/// cross-sheet pick. +fn pick_on_rates(h: &mut H, cell: &str) { + click_button(h, "Collapse dialog", 2); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + drag(h, cell, cell); + assert_eq!(field(h, "newLhs"), format!("Rates!${}${}", &cell[..1], &cell[1..])); + key(h, Key::Escape, Modifiers::NONE); + assert_eq!(selection(h).0, 1, "Rates is still shown"); +} + +#[test] +fn solver_reset_all_after_a_cross_sheet_pick_clears_the_dialogs_sheets_model() { + let mut h = harness(two_solver_models()); + let rates_names = model_names(&h, 1); + assert!(model_names(&h, 0) > 0 && rates_names > 0, "both sheets hold a model"); + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + pick_on_rates(&mut h, "C3"); + // Reset All clears the model of the dialog's sheet, not the sheet shown by the pick. + h.get_by_label("Reset All").click(); + h.run_steps(3); + assert_eq!(model_names(&h, 0), 0, "the dialog's sheet's model is cleared"); + assert_eq!(model_names(&h, 1), rates_names, "the other sheet's model is untouched"); + assert_eq!(selection(&h), (0, "B5".into()), "back on the dialog's sheet and selection"); + assert_eq!(field(&h, "objective"), "", "the form is reset"); +} + +#[test] +fn solver_save_model_after_a_cross_sheet_pick_writes_the_dialogs_sheet() { + let mut h = harness(two_sheets()); + h.state_mut().open_dialog("solver", json!({"objective": "B5", "variables": "B2"})); + h.run_steps(4); + h.get_by_label("Load/Save").click(); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("H1")); + h.run_steps(2); + pick_on_rates(&mut h, "C3"); + h.get_by_label("Save").click(); + h.run_steps(3); + let h1 = |h: &H, sheet: usize| h.state().session.active().unwrap().wb.sheets[sheet].value(CellRef::parse("H1").unwrap()); + assert!(!h1(&h, 0).is_empty(), "the model is written on the dialog's sheet"); + assert!(h1(&h, 1).is_empty(), "nothing is written on the sheet shown by the pick"); + assert_eq!(selection(&h).0, 0, "back on the dialog's sheet"); + // Saving where the model already is asks first; Replace, after another cross-sheet pick, + // writes the dialog's sheet too. + h.get_by_label("Save").click(); + h.run_steps(3); + assert!(h.query_by_label("Replace").is_some(), "the range holds the model: asked before replacing"); + pick_on_rates(&mut h, "C4"); + h.get_by_label("Replace").click(); + h.run_steps(3); + assert!(!h1(&h, 0).is_empty()); + assert!(h1(&h, 1).is_empty(), "Replace does not write the sheet shown by the pick"); + assert_eq!(selection(&h).0, 0); +} + +#[test] +fn solver_load_model_after_a_cross_sheet_pick_reads_the_dialogs_sheet() { + let mut s = two_sheets(); + // A model saved in cells from H1 of Sheet1, the dialog's sheet; Rates holds nothing there. + s.execute("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B5", "goal": "value", "value": 25, "variables": "B2"}})).unwrap(); + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({"objective": "", "variables": ""})); + h.run_steps(4); + h.get_by_label("Load/Save").click(); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("H1")); + h.run_steps(2); + pick_on_rates(&mut h, "C3"); + h.get_by_label("Load").click(); + h.run_steps(3); + assert!(field(&h, "objective").contains("B5"), "the model of the dialog's sheet is loaded: {:?}", field(&h, "objective")); + assert!(field(&h, "variables").contains("B2"), "{:?}", field(&h, "variables")); + assert_eq!(selection(&h).0, 0, "back on the dialog's sheet"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 354e88e09..62b9f7354 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,7 +65,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | -| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button | +| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after a pick on another sheet, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog whose reference boxes (RefEdit) picked on another sheet goes back to its own sheet: `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. | | `ui.pythonMode` (via `engine.execute`) | `{on?}` | Python mode on the active cell, in the formula bar (an edit in progress keeps its text); `on: false` turns it off; without `on` it switches. A start that is refused (a locked cell on a protected sheet, a cell or draft inside another cell's spill, code hidden on a protected sheet) is an error with the reason. Opens no dialog | | `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ba97eea4d..125182d27 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -173,7 +173,46 @@ the references entered before; a double-click goes to one. Its Reference box tak or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) and selects them all. New Formatting Rule's formula box points like the formula bar: a click on the grid writes the cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule -applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet. +applies to as it is (#2348). + +Reference boxes (RefEdit) take a range by selection on the grid: while a box is the one being +picked into, clicking or dragging on the sheet writes the absolute reference into it (with the +sheet name, quoted when it needs quotes, when the selection is on another sheet: +`'Rates Table'!$A$2:$A$4`), and typing still edits it. The box's collapse button shrinks the +dialog to the box while the person selects; Escape, Enter or the expand button restore it (Enter +there does not press OK, which is not shown while collapsed). Cancel, Escape, a ribbon command +that opens another dialog over it, and a scripted `ui.dialog.cancel` put the sheet and selection +from before picking back; OK (and `ui.dialog.confirm`, for a dialog whose OK runs a command; +Goal Seek presses its own OK and is scripted with `data.goalSeek`) puts them back before its +command runs and then stays where the command leaves things, so a Data Analysis report written +to a new sheet stays in view. Solver's Reset All, Load, Save and Replace go back the same way +before they run, since they act on the dialog's sheet, and are refused like OK once that sheet +was deleted. A text-box draft left open on the sheet picked on is saved before +the sheet changes, as any sheet switch saves it; when it cannot be saved (a script showed +another sheet meanwhile), OK does not run its command and the dialog stays open with the +reason, while Cancel still closes the dialog, shows the reason and leaves the draft and the +selection where they are (a scripted `ui.dialog.cancel` fails and keeps the dialog instead, so +the script can act). While collapsed, the arrow keys move the selection on the grid, +Shift+arrow extends it and Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data, and the box +follows; expanded, the arrows edit the box. F4 in a focused box cycles the anchors of its +reference (`A1`, `$A$1`, `A$1`, `$A1`; a whole column or row between `A:A` and `$A:$A`). A click +elsewhere (the Name Box, the ribbon) neither expands nor closes a collapsed dialog, and the box +keeps picking, while the arrow keys and Escape belong to the Name Box as long as it has the +keyboard; a command that moves the selection (Undo, Go To) moves the reference with it. A +box the dialog stops showing (Solver's Options view, the Model Range of a hidden Load/Save area, +the constraint value once the operator is int or bin) is no longer picked into: a selection +made meanwhile does not land in it when it is shown again. +The dialog follows the sheet it was opened on by the sheet's identity while the app runs +(fresh for every sheet added, inserted, copied or read from a file; kept through renames, +moves, undo and redo), wherever it is moved and whatever it is renamed to. A sheet deleted and +another inserted in its place or given its own name, or a copy of it, even in one script batch +or one undo step, is not taken for it: OK is refused and Cancel leaves the selection where it +is; an undo that brings the sheet back makes it the dialog's again. The collapse button is +reachable from the keyboard and named for screen readers. Goal Seek, Solver +(objective, variable cells, both sides of a new constraint and the Model Range of Load/Save) and +Data Analysis (input, variable, X and Y, bin and output ranges) use reference boxes; the other +dialogs that take ranges (Forecast Sheet, Allow Edit Ranges, Define Name, Sparklines, Advanced +Filter) still take typed references. ## Accessibility @@ -198,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | | 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection |