mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 02:51:33 +00:00
commit
89e141ca3e
36 files changed
+2566
-75
No files matched your search
Generated
+49
@@ -225,6 +225,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -4470,6 +4476,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -5002,6 +5019,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5231,6 +5254,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -6350,6 +6398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
|
||||
@@ -274,3 +274,34 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+3
-1
@@ -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 (form controls: buttons, sliders and spin buttons; 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)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: 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.
|
||||
@@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
|
||||
| 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 |
|
||||
@@ -258,6 +259,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
|
||||
| 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) |
|
||||
|
||||
@@ -24,9 +24,13 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> = None;
|
||||
let mut language: Option<String> = None;
|
||||
let mut hf_edit: Option<String> = None;
|
||||
let mut refedit: Option<String> = 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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RefPick>,
|
||||
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
|
||||
original_link_text: Option<String>,
|
||||
/// 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<bool> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<f64>().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<String, Json>) -> Vec<Field> {
|
||||
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<String, Json>) -> Vec<Field> {
|
||||
],
|
||||
"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<String, Json>) -> Vec<Field> {
|
||||
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<String> = 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() {
|
||||
|
||||
@@ -150,15 +150,15 @@ pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec<Field> {
|
||||
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));
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Home>,
|
||||
/// 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<String>,
|
||||
/// 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<egui::Rect>,
|
||||
/// 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<bool> {
|
||||
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::<Vec<_>>().join(",")
|
||||
}
|
||||
_ => areas.collect::<Vec<_>>().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<String>) {
|
||||
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<egui::Rect>) {
|
||||
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<String> = 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<Anchors> {
|
||||
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<Anchors>)> = 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::<Option<Vec<_>>>() 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<String> = parts.iter().map(|a| format!("{}{}", anchor(col_abs, &a.letters), anchor(row_abs, &a.digits))).collect();
|
||||
format!("{prefix}{}", body.join(":"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -158,8 +159,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)
|
||||
@@ -172,12 +174,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),
|
||||
@@ -189,7 +197,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),
|
||||
@@ -369,9 +377,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 {
|
||||
@@ -402,6 +411,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) {
|
||||
@@ -428,6 +449,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.
|
||||
@@ -474,8 +508,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);
|
||||
}
|
||||
|
||||
@@ -521,7 +555,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| {
|
||||
@@ -550,6 +584,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| {
|
||||
|
||||
@@ -580,6 +580,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,
|
||||
})
|
||||
}
|
||||
@@ -500,6 +500,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,
|
||||
})
|
||||
}
|
||||
@@ -1005,6 +1005,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Καταγραφή",
|
||||
"New Macro" => "Νέα μακροεντολή",
|
||||
"Show" => "Εμφάνιση",
|
||||
"Collapse dialog" => "Σύμπτυξη παραθύρου διαλόγου",
|
||||
"Expand dialog" => "Ανάπτυξη παραθύρου διαλόγου",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -506,6 +506,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,
|
||||
})
|
||||
}
|
||||
@@ -476,6 +476,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,
|
||||
})
|
||||
}
|
||||
@@ -1010,6 +1010,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,
|
||||
})
|
||||
}
|
||||
@@ -1011,6 +1011,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
|
||||
|
||||
@@ -470,6 +470,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "중지",
|
||||
"New Macro" => "새 매크로",
|
||||
"Show" => "표시",
|
||||
"Collapse dialog" => "대화 상자 축소",
|
||||
"Expand dialog" => "대화 상자 확장",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -551,6 +551,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,
|
||||
})
|
||||
}
|
||||
@@ -999,6 +999,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,
|
||||
})
|
||||
}
|
||||
@@ -1007,6 +1007,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,
|
||||
})
|
||||
}
|
||||
@@ -1061,6 +1061,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,
|
||||
})
|
||||
}
|
||||
@@ -474,6 +474,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Остановить",
|
||||
"New Macro" => "Новый макрос",
|
||||
"Show" => "Показать",
|
||||
"Collapse dialog" => "Свернуть диалоговое окно",
|
||||
"Expand dialog" => "Развернуть диалоговое окно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -471,6 +471,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,
|
||||
})
|
||||
}
|
||||
@@ -441,6 +441,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,
|
||||
})
|
||||
}
|
||||
@@ -1003,6 +1003,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Запис",
|
||||
"New Macro" => "Новий макрос",
|
||||
"Show" => "Показати",
|
||||
"Collapse dialog" => "Згорнути діалогове вікно",
|
||||
"Expand dialog" => "Розгорнути діалогове вікно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -441,6 +441,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,
|
||||
})
|
||||
}
|
||||
@@ -462,6 +462,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "停止",
|
||||
"New Macro" => "新建宏",
|
||||
"Show" => "显示",
|
||||
"Collapse dialog" => "折叠对话框",
|
||||
"Expand dialog" => "展开对话框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -90,6 +90,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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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` |
|
||||
|
||||
+42
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; 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 281 / 296) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: 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 281 / 296) · **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
|
||||
@@ -174,7 +174,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
|
||||
|
||||
@@ -200,6 +239,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 281 / 296 |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user