mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
* UI: RefEdit reference boxes in dialogs; Goal Seek picks its cells on the grid A reference box (RefEdit) is a text field that takes a range by selection on the grid. While a box is the one being picked into (it was clicked into or collapsed), clicking or dragging on the sheet writes the absolute reference into it, with the sheet name when the selection is on another sheet; typing still edits it, and the box takes the keyboard back after a pick. Focusing another text field of the dialog ends picking. The collapse button beside each box shrinks the dialog to that box, at the dialog's top-left corner, so the sheet is free for selecting. Escape, Enter or the expand button restore the dialog. The sheet and selection from before picking are restored before the dialog runs its command (references without a sheet name mean the sheet the dialog was opened on) and when it closes. The button is focusable, works with Space and Enter, and is named for screen readers; the icons are drawn in code. Goal Seek's Set cell and By changing cell are reference boxes. The snapshot example's --refedit key=range collapses the open dialog to a box and selects a range, to render the collapsed state. * UI: Solver and Data Analysis take their ranges with RefEdit boxes Form dialogs gain a Ref field, drawn as a reference box with its collapse button. Data Analysis uses it for the input, variable, X and Y and bin ranges, and for Output Range, where picking or typing a reference also chooses that output option. Solver Parameters uses reference boxes for the objective, the variable cells and both sides of a new constraint. * UI: Data Analysis keeps New Worksheet Ply when chosen after picking an Output Range The Output Range box chose its option on every frame while it was the box being picked into, so clicking New Worksheet Ply after using the box reverted to Output Range on the next frame. The box now chooses Output Range only when it is clicked into or its text changes (typed or picked), and choosing another output option ends picking into it, so a later selection on the grid doesn't fill it either. * UI: RefEdit boxes remember the dialog's sheet by name The sheet a dialog was opened on was kept by index, so a sheet inserted or moved while picking (the ribbon stays usable) made a range on the new sheet lose its sheet name, and closing the dialog went back to whichever sheet now had that index. The sheet is now kept by name, as New Formatting Rule's formula box does. * UI: switching sheets while picking into a RefEdit box doesn't fill it A reference on another sheet carries the sheet name, so after a click on another sheet tab the selection never matched the one last seen, and the switch alone wrote that sheet's remembered selection into the box, replacing a typed reference. The box now notes which sheet its last seen selection was on: showing another sheet only refreshes it, and a selection made on that sheet still fills the box. * UI: a RefEdit box for one reference takes only the area being selected A selection of several areas (Ctrl+click) was written into every box as a comma-joined union, which Goal Seek, Solver's objective and constraints, and Data Analysis then refused as an invalid reference. Solver's variable cells still take every area; the other boxes take the area being selected. * UI: RefEdit boxes follow the dialog's sheet when it is renamed The dialog's own sheet was kept by name only, so renaming it while a reference box picked made references on it carry the new name as if it were another sheet, and running or closing the dialog could not put the selection back. The sheet's index and the sheet count are now kept with the name: when the name disappears while the count stays the same, the sheet at that index was renamed and its new name is taken. Inserting, moving or deleting other sheets still follows the name. * UI: a deleted dialog sheet is never taken for another sheet The dialog's own sheet was taken as renamed whenever its name was gone and the sheet count matched the last frame's, so deleting it was read as a rename a frame later (or after a sheet was inserted), and the surviving sheet at that index became the dialog's sheet: unqualified references ran against it and Cancel put the old selection on it. The sheet names of the last frame are now kept in order. A rename is taken only when the list keeps its length and only the home sheet's entry changed; any other loss of the name marks the sheet as deleted for good. Once it is deleted, picks carry their sheet's name, closing leaves the selection where it is, and OK is refused with a message, since references without a sheet name would otherwise run against whichever sheet is shown. * UI: RefEdit dialogs keep their sheet until they close OK put the saved selection back and dropped the dialog's sheet with it. When the command then failed, the dialog stayed open without its sheet: a later deletion of that sheet went unnoticed and a second OK ran references without a sheet name against the sheet shown, as it also did when the person had only moved to another sheet after the failed OK. The sheet is now kept, and followed through renames and deletion, until the dialog closes; only the saved selection is put back once. A later OK or Cancel shows the dialog's sheet again (or is refused once it is deleted), and picking again on that sheet saves its selection anew. * UI: Solver's Model Range is a RefEdit box * UI: a collapsed RefEdit box keeps picking when something else is clicked * UI: a dialog opened over a picking RefEdit dialog puts the selection back * UI: arrow keys select on the grid while a dialog is collapsed to a RefEdit box * UI: F4 cycles the anchors of the reference in a RefEdit box * UI: tests for Enter while collapsed and OK after a cross-sheet pick * Docs: RefEdit boxes' keyboard route, Model Range and click-elsewhere behaviour * UI: a confirmed RefEdit dialog stays on the sheet its command showed * UI: scripted dialog cancel and confirm end a RefEdit pick like the buttons * UI: RefEdit boxes run their selection and sheet commands through the app * Docs: RefEdit dialogs after OK, scripted cancel and confirm * UI: a RefEdit dialog's OK stops when the picked sheet's draft cannot be saved * Docs: ui.dialog.confirm applies to command-backed dialogs; a draft that cannot be saved stops OK * UI: Cancel of a RefEdit dialog is never a confirmation, and says when it cannot go home * Docs: cancelling a RefEdit dialog when its sheet cannot be shown again * UI: a RefEdit dialog follows its sheet's rename only through the engine's recorded steps * UI: F4 in a RefEdit box splits areas only at commas outside quoted sheet names * Docs: how a RefEdit dialog follows its sheet's rename * UI: a RefEdit dialog's sheet is told from a new sheet given its name The dialog followed its sheet by name and read the recorded steps only once the name was gone, so a sheet deleted and recreated under its own name between two frames was taken for the dialog's sheet, and OK resolved unqualified references against the replacement. The home sheet is now followed through every recorded step since the dialog last looked: a sheet with its name is the same sheet only while its file id is unchanged (a sheet inserted in the session has none), a rename keeps the file id and the cells, and a deleted sheet is remembered as it was, so only an undo that brings that very sheet back revives it. When the steps are not accounted for, a sheet with the name is taken for it, as before. A sheet never saved to a file and replaced inside one undo group is the one case nothing in the model tells apart; the code comment says so. * Docs: how a RefEdit dialog tells its sheet from a new sheet given its name * UI: a RefEdit dialog follows a sheet that came from a file by its file id A sheet read from a file keeps the file's sheetId, which no step changes and which a sheet inserted in the session lacks, so the dialog's sheet is found by it wherever a recorded step puts it and whatever the step renames it to: a group that renames the sheet and inserts a new one under its old name follows the rename instead of refusing OK. When the step also copied the sheet (a copy keeps the id), the one keeping the name is taken. A sheet never saved to a file is still followed by name, with the rename rule as before. * Docs: a RefEdit dialog follows a sheet that came from a file by its file id * UI: a RefEdit box the dialog stops showing is no longer picked into A reference box compares the grid selection with the one it last saw only while it is drawn, so a box hidden while it was the one being picked into (Solver's Options view, the constraint value once the operator is int or bin) took a selection made meanwhile when it came back. After the dialog's window is drawn, a target box that was not drawn in the frame ends the pick, whatever hid it; Model Range no longer needs its own rule when the Load/Save area is hidden. * UI: the Name Box keeps its keys while a dialog is collapsed to a RefEdit box The collapsed picker took Escape, Enter and the arrow keys out of the frame's input whoever had the keyboard. The Name Box, drawn before the dialogs, had already read an arrow when the picker removed the same event and moved the grid selection, and the box then took that selection and the keyboard; its Escape expanded the dialog instead of only leaving the box. The picker now acts on those keys only when no other text field owns the keyboard, judged by the focus at the end of the frame before as well as now, since egui drops the focus for Escape before anything is drawn. The other field's Escape and Enter are still used up, so the dialog neither closes nor confirms on them. * Docs: a sentence break in the ui.dialog row of the control protocol * UI: a RefEdit dialog keeps its sheet by the sheet's identity, not by file id or history The dialog's sheet was followed by name through the recorded undo steps, with the file's sheet id telling a saved sheet from a new one; a copy made with sheet.move keeps that id, so once the original was deleted the copy, as the only sheet of the id, was taken for the dialog's sheet and OK ran against it. The model now gives every sheet an identity while the app runs (SheetUid: fresh for every sheet added, inserted, copied or read, kept through renames, moves, undo and redo), so Home keeps the sheet's uid and finds the sheet by it each time it is needed. The history walk, the rename rule and the file-id and Arc comparisons are gone, with the undetectable single-step case they left: a sheet given the name, a replacement inside one undo group and a copy are all other sheets, and an undo that brings the original back makes it the dialog's again. Nothing of the unaccounted-steps fallback is kept, since the identity always answers. Regression test: copy the dialog's sheet, delete the original, OK is refused; undo, OK runs on the original. The identity tests no longer need a workbook that came through a file. * UI: Solver's Reset All, Load, Save and Replace act on the dialog's sheet, as Solve does Only Solve went through the dialog's home-sheet checks before running (dialogs::show): Reset All, Load Model, Save Model and Replace ran their commands straight from the form, on whichever sheet was active. After a reference box picked on another sheet and the dialog expanded, that sheet was still shown, so Reset All removed its model instead of the dialog's sheet's, and Load and Save read and wrote the model range on it. The four buttons now run only after the same checks as Solve: the dialog's sheet must still exist (else the command is refused with the deleted-sheet message) and is shown again with the selection from before picking put back (a failure to show it, a text-box draft that could not be saved, is shown as the dialog's error and the command does not run). Goal Seek has no other buttons, and Data Analysis's Replace goes through the confirm path that Solve uses, so neither needed a change. Regression tests: after a cross-sheet pick, Reset All clears the dialog's sheet's model and leaves the other sheet's; Save and Replace write the dialog's sheet; Load reads it. * UI: Protect Sheet opened while a reference box picks on another sheet acts on the dialog's sheet * Docs: a scripted Goal Seek after a cross-sheet pick cancels the dialog before data.goalSeek * UI: the Protect Sheet labels follow the dialog's sheet while a reference box picks on another * Docs: a scripted Goal Seek checks that the dialog's sheet still exists after cancel * Control: ui.dialog.cancel replies sheetDeleted when the dialog's own sheet is gone, even if its name was reused * M2: allow shared-runner variance in the pathological math timing gate * M2: refuse sheet protection actions after a reference dialog home is deleted --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
1174 lines
56 KiB
Rust
1174 lines
56 KiB
Rust
//! Dialogs. Simple dialogs are forms (fields → a command with params); Format Cells, Insert
|
||
//! Function, Find & Replace, Sort, Name Manager and the command palette are custom.
|
||
//!
|
||
//! Every dialog is also reachable programmatically: `ui.dialog {name}` opens one,
|
||
//! `ui.dialog.set {field, value}` edits a field and `ui.dialog.confirm` presses OK.
|
||
|
||
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
|
||
|
||
use crate::icons::{self, Icon};
|
||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||
use serde_json::{Map, Value as Json, json};
|
||
|
||
use crate::SheetApp;
|
||
use crate::theme;
|
||
|
||
mod about;
|
||
mod analysis;
|
||
mod charts;
|
||
mod command_search;
|
||
mod cond_format_rules;
|
||
mod data;
|
||
mod find;
|
||
mod format_cells;
|
||
mod insert_function;
|
||
mod names;
|
||
mod paste;
|
||
mod ref_edit;
|
||
mod show;
|
||
mod spelling;
|
||
mod zoom;
|
||
|
||
use analysis::toolpak_defaults;
|
||
use charts::select_chart;
|
||
use data::{abs, data_validation_fields, remove_duplicates_range, validation_defaults};
|
||
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)> },
|
||
Check { key: &'static str, label: &'static str },
|
||
Note(String),
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Dialog {
|
||
pub name: String,
|
||
pub title: String,
|
||
pub fields: Vec<Field>,
|
||
pub values: Map<String, Json>,
|
||
/// Command to run on OK (params = values, transformed by `build`).
|
||
pub command: Option<&'static str>,
|
||
pub tab: String,
|
||
pub search: String,
|
||
pub list_index: usize,
|
||
pub result: Option<Json>,
|
||
/// "Show password" is ticked: password fields show their text.
|
||
pub show_password: bool,
|
||
/// The first text or number field still has to take keyboard focus (set when a form opens,
|
||
/// used once): like Excel, Column Width opens ready to type, its value selected.
|
||
focus_first_field: bool,
|
||
picture_inbox: Option<crate::Inbox>,
|
||
picture_target: Option<(u64, usize)>,
|
||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||
back: Option<Box<Dialog>>,
|
||
pub(crate) focus_initialized: bool,
|
||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||
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
|
||
/// reference at the caret instead of selecting it, and a drag grows it into a range.
|
||
#[derive(Clone, Debug)]
|
||
struct RefPick {
|
||
key: &'static str,
|
||
/// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or
|
||
/// moved while the dialog is open. References to another sheet carry that sheet's name.
|
||
sheet: String,
|
||
/// Caret (char index), mirrored from the text box while it has the keyboard.
|
||
caret: usize,
|
||
/// The reference last inserted (byte span), replaced by the next click or the drag.
|
||
point: Option<(usize, usize)>,
|
||
anchor: CellRef,
|
||
/// Give the box the keyboard back, caret after the reference, on the next frame.
|
||
refocus: bool,
|
||
/// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to":
|
||
/// pointing on another sheet must not move the rule there.
|
||
applies_to: String,
|
||
}
|
||
|
||
impl Dialog {
|
||
pub fn name(&self) -> &str {
|
||
&self.name
|
||
}
|
||
|
||
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom`
|
||
/// stores, a typed percentage selects Custom, and Fit selection makes OK run
|
||
/// `view.zoomToSelection` (its view size was captured when the dialog opened).
|
||
pub(crate) fn set_value(&mut self, key: &str, value: Json) {
|
||
if self.name == "zoom" {
|
||
match key {
|
||
"preset" => match value.as_str() {
|
||
Some("fit") => self.command = Some("view.zoomToSelection"),
|
||
Some(s) => {
|
||
self.command = Some("view.zoom");
|
||
if let Ok(p) = s.parse::<f64>() {
|
||
self.values.insert("percent".into(), json!(p));
|
||
}
|
||
}
|
||
None => {}
|
||
},
|
||
"percent" => {
|
||
self.command = Some("view.zoom");
|
||
self.values.insert("preset".into(), json!(zoom_preset(value.as_f64().unwrap_or(0.0))));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
self.values.insert(key.into(), value);
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the
|
||
/// formula box's caret, replacing the reference the press inserted, as in the formula bar's
|
||
/// point mode. An empty box starts with `=`. After an operand there is no place for a
|
||
/// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the
|
||
/// dialog's own.
|
||
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) {
|
||
let Some(pick) = self.pick.as_mut() else { return };
|
||
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
|
||
let mut ed = crate::editor::EditState::new(0, 0, cell, text, true, false);
|
||
ed.caret = pick.caret.min(ed.text.chars().count());
|
||
ed.point = pick.point;
|
||
if ed.text.trim().is_empty() {
|
||
ed.text = "=".into();
|
||
ed.caret = 1;
|
||
ed.point = None;
|
||
}
|
||
if !ed.can_point() {
|
||
return;
|
||
}
|
||
if !extend {
|
||
pick.anchor = cell;
|
||
}
|
||
let range = abs(&RangeRef::new(pick.anchor, cell));
|
||
let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) {
|
||
range
|
||
} else {
|
||
format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet))
|
||
};
|
||
ed.insert_ref(&reference);
|
||
pick.caret = ed.caret;
|
||
pick.point = ed.point;
|
||
pick.refocus = true;
|
||
self.values.insert(pick.key.into(), json!(ed.text));
|
||
}
|
||
|
||
/// Creates a command-backed form with its initial field values.
|
||
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
|
||
Dialog {
|
||
name: name.into(),
|
||
title: title.into(),
|
||
fields,
|
||
values: defaults.as_object().cloned().unwrap_or_default(),
|
||
command: Some(command),
|
||
tab: String::new(),
|
||
search: String::new(),
|
||
list_index: 0,
|
||
result: None,
|
||
show_password: false,
|
||
focus_first_field: true,
|
||
picture_inbox: None,
|
||
picture_target: None,
|
||
back: None,
|
||
focus_initialized: false,
|
||
pick: None,
|
||
original_link_text: None,
|
||
refs: ref_edit::RefBoxes::default(),
|
||
}
|
||
}
|
||
|
||
/// Creates a dialog whose body handles its own controls instead of a standard form.
|
||
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
|
||
Dialog {
|
||
name: name.into(),
|
||
title: title.into(),
|
||
fields: vec![],
|
||
values: defaults.as_object().cloned().unwrap_or_default(),
|
||
command: None,
|
||
tab: String::new(),
|
||
search: String::new(),
|
||
list_index: 0,
|
||
result: None,
|
||
show_password: false,
|
||
focus_first_field: false,
|
||
picture_inbox: None,
|
||
picture_target: None,
|
||
back: None,
|
||
focus_initialized: false,
|
||
pick: None,
|
||
original_link_text: None,
|
||
refs: ref_edit::RefBoxes::default(),
|
||
}
|
||
}
|
||
|
||
/// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink.
|
||
pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option<Dialog> {
|
||
let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default();
|
||
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
|
||
let p = |k: &str| params.get(k).cloned().unwrap_or(Json::Null);
|
||
use Field::*;
|
||
let d = match name {
|
||
"insertCells" => Dialog::form(
|
||
"insertCells",
|
||
"Insert",
|
||
"home.insertCells",
|
||
vec![Choice {
|
||
key: "shift",
|
||
label: "Insert",
|
||
options: vec![("right", "Shift cells right"), ("down", "Shift cells down"), ("row", "Entire row"), ("column", "Entire column")],
|
||
}],
|
||
json!({"shift": "down"}),
|
||
),
|
||
"arrangeAll" => Dialog::form(
|
||
"arrangeAll",
|
||
"Arrange Windows",
|
||
"view.arrangeAll",
|
||
vec![
|
||
Choice {
|
||
key: "arrange",
|
||
label: "Arrange",
|
||
options: vec![("tiled", "Tiled"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("cascade", "Cascade")],
|
||
},
|
||
Check { key: "activeWorkbook", label: "Windows of active workbook" },
|
||
],
|
||
json!({"arrange": "tiled", "activeWorkbook": false}),
|
||
),
|
||
"insertPaste" => Dialog::form(
|
||
"insertPaste",
|
||
"Insert Paste",
|
||
"home.insertCopiedCells",
|
||
vec![Choice { key: "shift", label: "Insert", options: vec![("right", "Shift cells right"), ("down", "Shift cells down")] }],
|
||
json!({"shift": "down"}),
|
||
),
|
||
"deleteCells" => Dialog::form(
|
||
"deleteCells",
|
||
"Delete",
|
||
"home.deleteCells",
|
||
vec![Choice {
|
||
key: "shift",
|
||
label: "Delete",
|
||
options: vec![("left", "Shift cells left"), ("up", "Shift cells up"), ("row", "Entire row"), ("column", "Entire column")],
|
||
}],
|
||
json!({"shift": "up"}),
|
||
),
|
||
"rowHeight" => Dialog::form(
|
||
"rowHeight",
|
||
"Row Height",
|
||
"home.rowHeight",
|
||
vec![Number { key: "points", label: "Row height (points):" }],
|
||
json!({"points": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row) as f64 * 0.75)).unwrap_or(15.0)}),
|
||
),
|
||
"columnWidth" => Dialog::form(
|
||
"columnWidth",
|
||
"Column Width",
|
||
"home.columnWidth",
|
||
vec![Number { key: "chars", label: "Column width (characters):" }],
|
||
json!({"chars": app.session.active().and_then(|d| d.wb.active().map(|s| gridcraft_engine::cmd::format::points_to_chars(s.col_width(d.selection.active.col) as f64))).unwrap_or(8.43)}),
|
||
),
|
||
"defaultWidth" => Dialog::form(
|
||
"defaultWidth",
|
||
"Standard Width",
|
||
"home.defaultWidth",
|
||
vec![Number { key: "width", label: "Standard column width (pixels):" }],
|
||
json!({"width": 64}),
|
||
),
|
||
"renameSheet" => Dialog::form(
|
||
"renameSheet",
|
||
"Rename Sheet",
|
||
"sheet.rename",
|
||
vec![Text { key: "name", label: "Name:" }],
|
||
json!({"name": app.session.active().and_then(|d| d.wb.active().map(|s| s.name.clone()))}),
|
||
),
|
||
"moveSheet" => {
|
||
let sheets: Vec<String> = app.session.active().map(|d| d.wb.sheets.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
|
||
let mut d = Dialog::form(
|
||
"moveSheet",
|
||
"Move or Copy",
|
||
"sheet.move",
|
||
vec![
|
||
Number { key: "to", label: "Before sheet (position, 0 = first):" },
|
||
Check { key: "copy", label: "Create a copy" },
|
||
Note(format!("Sheets: {}", sheets.join(", "))),
|
||
],
|
||
json!({"to": 0, "copy": false}),
|
||
);
|
||
if let Some(s) = params.get("sheet") {
|
||
d.values.insert("sheet".into(), s.clone());
|
||
}
|
||
d
|
||
}
|
||
"protectSheet" => Dialog::form(
|
||
"protectSheet",
|
||
"Protect Sheet",
|
||
"review.protectSheet",
|
||
vec![
|
||
Password { key: "password", label: "Password (optional):" },
|
||
Note("Allow all users of this sheet to:".into()),
|
||
Check { key: "selectLockedCells", label: "Select locked cells" },
|
||
Check { key: "selectUnlockedCells", label: "Select unlocked cells" },
|
||
Check { key: "formatCells", label: "Format cells" },
|
||
Check { key: "formatColumns", label: "Format columns" },
|
||
Check { key: "formatRows", label: "Format rows" },
|
||
Check { key: "insertColumns", label: "Insert columns" },
|
||
Check { key: "insertRows", label: "Insert rows" },
|
||
Check { key: "deleteColumns", label: "Delete columns" },
|
||
Check { key: "deleteRows", label: "Delete rows" },
|
||
Check { key: "sort", label: "Sort" },
|
||
Check { key: "autofilter", label: "Use AutoFilter" },
|
||
],
|
||
json!({"selectLockedCells": true, "selectUnlockedCells": true}),
|
||
),
|
||
"allowEditRanges" => {
|
||
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
|
||
let rows: Vec<String> = list
|
||
.as_array()
|
||
.map(|a| {
|
||
a.iter()
|
||
.map(|r| {
|
||
let lock = match (r["password"].as_bool(), r["unlocked"].as_bool()) {
|
||
(Some(true), Some(true)) => " (password, unlocked)",
|
||
(Some(true), _) => " (password)",
|
||
_ => "",
|
||
};
|
||
format!("{}: {}{lock}", r["title"].as_str().unwrap_or(""), r["range"].as_str().unwrap_or(""))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let summary = if rows.is_empty() { "No ranges yet.".to_string() } else { rows.join("\n") };
|
||
Dialog::form(
|
||
"allowEditRanges",
|
||
"Allow Edit Ranges",
|
||
"review.allowEditRanges",
|
||
vec![
|
||
Note(format!("Ranges that stay editable when the sheet is protected:\n{summary}")),
|
||
Choice {
|
||
key: "action",
|
||
label: "Action",
|
||
options: vec![("add", "New"), ("modify", "Modify"), ("delete", "Delete"), ("unlock", "Unlock with password")],
|
||
},
|
||
Text { key: "title", label: "Title:" },
|
||
Text { key: "range", label: "Refers to cells:" },
|
||
Text { key: "password", label: "Range password:" },
|
||
Text { key: "newTitle", label: "New title (Modify):" },
|
||
Check { key: "removePassword", label: "Remove the password (Modify)" },
|
||
],
|
||
json!({"action": "add", "range": sel}),
|
||
)
|
||
}
|
||
"protectWorkbook" => Dialog::form(
|
||
"protectWorkbook",
|
||
"Protect Structure and Windows",
|
||
"review.protectWorkbook",
|
||
vec![
|
||
Password { key: "password", label: "Password (optional):" },
|
||
Note("Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted).".into()),
|
||
],
|
||
json!({"on": true}),
|
||
),
|
||
"unprotectWorkbook" => Dialog::form(
|
||
"unprotectWorkbook",
|
||
"Unprotect Workbook",
|
||
"review.protectWorkbook",
|
||
vec![Password { key: "password", label: "Password:" }],
|
||
json!({"on": false}),
|
||
),
|
||
"openPassword" => {
|
||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||
// on the web) and goes back to `file.open` with the password.
|
||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||
let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("This workbook").to_string();
|
||
// An empty password from the start, so OK on the untouched field says "incorrect"
|
||
// rather than asking for the password again.
|
||
let mut values = params.clone();
|
||
values["password"] = json!("");
|
||
Dialog::form(
|
||
"openPassword",
|
||
"Password",
|
||
"file.open",
|
||
vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }],
|
||
values,
|
||
)
|
||
}
|
||
"unprotectSheet" => Dialog::form(
|
||
"unprotectSheet",
|
||
"Unprotect Sheet",
|
||
"review.unprotectSheet",
|
||
vec![Password { key: "password", label: "Password:" }],
|
||
json!({}),
|
||
),
|
||
"unhideSheet" => {
|
||
let opts: Vec<String> =
|
||
p("sheets").as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
let first = opts.first().cloned().unwrap_or_default();
|
||
let mut d = Dialog::form(
|
||
"unhideSheet",
|
||
"Unhide",
|
||
"sheet.unhide",
|
||
vec![Text { key: "sheet", label: "Unhide sheet:" }, Note(format!("Hidden: {}", opts.join(", ")))],
|
||
json!({"sheet": first}),
|
||
);
|
||
d.values.insert("sheet".into(), json!(first));
|
||
d
|
||
}
|
||
"pasteSpecial" => Dialog::form(
|
||
"pasteSpecial",
|
||
"Paste Special",
|
||
"edit.pasteSpecial",
|
||
vec![
|
||
Choice {
|
||
key: "what",
|
||
label: "Paste",
|
||
options: vec![
|
||
("all", "All"),
|
||
("formulas", "Formulas (F)"),
|
||
("values", "Values (V)"),
|
||
("formats", "Formats (T)"),
|
||
("comments", "Comments and notes (C)"),
|
||
("validation", "Validation"),
|
||
("allExceptBorders", "All except borders"),
|
||
("columnWidths", "Column widths"),
|
||
("formulasAndNumberFormats", "Formulas and number formats"),
|
||
("valuesAndNumberFormats", "Values and number formats"),
|
||
],
|
||
},
|
||
Choice {
|
||
key: "operation",
|
||
label: "Operation",
|
||
options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")],
|
||
},
|
||
Check { key: "skipBlanks", label: "Skip blanks" },
|
||
Check { key: "transpose", label: "Transpose (E)" },
|
||
Check { key: "link", label: "Paste Link" },
|
||
],
|
||
json!({"what": "all", "operation": "none"}),
|
||
),
|
||
"series" => Dialog::form(
|
||
"series",
|
||
"Series",
|
||
"edit.fillSeries",
|
||
vec![
|
||
Choice { key: "direction", label: "Series in", options: vec![("columns", "Columns"), ("rows", "Rows")] },
|
||
Choice { key: "type", label: "Type", options: vec![("linear", "Linear"), ("growth", "Growth"), ("date", "Date")] },
|
||
Choice {
|
||
key: "dateUnit",
|
||
label: "Date unit",
|
||
options: vec![("day", "Day"), ("weekday", "Weekday"), ("month", "Month"), ("year", "Year")],
|
||
},
|
||
Number { key: "step", label: "Step value:" },
|
||
Text { key: "stop", label: "Stop value:" },
|
||
],
|
||
json!({"direction": if p("direction") == "rows" { "rows" } else { "columns" }, "type": "linear", "dateUnit": "day", "step": 1}),
|
||
),
|
||
"comment" => {
|
||
let threaded = p("threaded").as_bool().unwrap_or(true);
|
||
let existing = app
|
||
.session
|
||
.active()
|
||
.and_then(|d| d.wb.active().and_then(|s| s.comments.get(&d.selection.active).map(|c| c.text.clone())))
|
||
.unwrap_or_default();
|
||
Dialog::form(
|
||
if threaded { "comment" } else { "note" },
|
||
if threaded { "New Comment" } else { "New Note" },
|
||
if threaded { "review.newComment" } else { "review.newNote" },
|
||
vec![Text { key: "text", label: if threaded { "Start a conversation:" } else { "Note:" } }],
|
||
json!({"text": existing}),
|
||
)
|
||
}
|
||
// Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which
|
||
// refuses anything but http, https and mailto.
|
||
"openLink" => {
|
||
let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();
|
||
Dialog::form(
|
||
"openLink",
|
||
"Open Hyperlink",
|
||
"link.open",
|
||
vec![Note(format!(
|
||
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
|
||
))],
|
||
json!({"url": url}),
|
||
)
|
||
}
|
||
"insertLink" => {
|
||
let mut d = Dialog::form(
|
||
"insertLink",
|
||
"Insert Hyperlink",
|
||
"insert.link",
|
||
vec![
|
||
Text { key: "text", label: "Display:" },
|
||
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
|
||
Text { key: "tooltip", label: "ScreenTip:" },
|
||
],
|
||
json!({"target": "https://"}),
|
||
);
|
||
if let Some(doc) = app.session.active()
|
||
&& let Some(sheet) = doc.wb.active()
|
||
&& let Some(link) = sheet.hyperlinks.get(&doc.selection.active)
|
||
{
|
||
let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active);
|
||
d.values.insert("target".into(), json!(link.target));
|
||
d.values.insert("text".into(), json!(text));
|
||
d.values.insert("tooltip".into(), json!(link.tooltip));
|
||
d.original_link_text = Some(text);
|
||
}
|
||
d
|
||
}
|
||
"sparkline" => Dialog::form(
|
||
"sparkline",
|
||
"Create Sparklines",
|
||
"insert.sparkline",
|
||
vec![
|
||
Text { key: "range", label: "Data Range:" },
|
||
Text { key: "location", label: "Location Range:" },
|
||
Check { key: "markers", label: "Markers" },
|
||
],
|
||
json!({"range": sel, "location": active, "type": p("type")}),
|
||
),
|
||
"headerFooter" => Dialog::form(
|
||
"headerFooter",
|
||
"Header & Footer",
|
||
"pageLayout.headerFooter",
|
||
vec![
|
||
Text { key: "header", label: "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" },
|
||
Text { key: "footer", label: "Footer:" },
|
||
],
|
||
{
|
||
let sheet = app.session.active().and_then(|d| d.wb.active());
|
||
let (header, footer) = sheet.map(|s| (s.print.header.clone(), s.print.footer.clone())).unwrap_or_default();
|
||
json!({"header": header, "footer": footer})
|
||
},
|
||
),
|
||
"pageSetup" => {
|
||
// Opens on the active sheet's saved margins and page order, so OK without edits
|
||
// rewrites them unchanged. One `pageLayout.pageSetup` sets them all: one undo step.
|
||
let (margins, order) = app
|
||
.session
|
||
.active()
|
||
.and_then(|d| d.wb.active())
|
||
.map(|s| (s.print.margins, s.print.page_order))
|
||
.unwrap_or(([0.7, 0.7, 0.75, 0.75, 0.3, 0.3], Default::default()));
|
||
// Round off f32 noise so the form shows 0.1, not 0.10000000149.
|
||
let [left, right, top, bottom, header, footer] = margins.map(|v| (f64::from(v) * 1e4).round() / 1e4);
|
||
Dialog::form(
|
||
"pageSetup",
|
||
"Page Setup",
|
||
"pageLayout.pageSetup",
|
||
vec![
|
||
Number { key: "left", label: "Left margin (in):" },
|
||
Number { key: "right", label: "Right margin (in):" },
|
||
Number { key: "top", label: "Top margin (in):" },
|
||
Number { key: "bottom", label: "Bottom margin (in):" },
|
||
Number { key: "header", label: "Header (in):" },
|
||
Number { key: "footer", label: "Footer (in):" },
|
||
Choice {
|
||
key: "pageOrder",
|
||
label: "Page order",
|
||
options: vec![("downThenOver", "Down, then over"), ("overThenDown", "Over, then down")],
|
||
},
|
||
],
|
||
json!({"left": left, "right": right, "top": top, "bottom": bottom, "header": header, "footer": footer, "pageOrder": order.id()}),
|
||
)
|
||
}
|
||
"textToColumns" => Dialog::form(
|
||
"textToColumns",
|
||
"Convert Text to Columns",
|
||
"data.textToColumns",
|
||
vec![
|
||
Check { key: "tab", label: "Tab" },
|
||
Check { key: "semicolon", label: "Semicolon" },
|
||
Check { key: "comma", label: "Comma" },
|
||
Check { key: "space", label: "Space" },
|
||
Text { key: "other", label: "Other:" },
|
||
Check { key: "treatConsecutive", label: "Treat consecutive delimiters as one" },
|
||
Text { key: "destination", label: "Destination:" },
|
||
],
|
||
json!({"tab": true, "comma": true, "destination": active}),
|
||
),
|
||
"removeDuplicates" => Dialog::form(
|
||
"removeDuplicates",
|
||
"Remove Duplicates",
|
||
"data.removeDuplicates",
|
||
vec![
|
||
Text { key: "range", label: "Range:" },
|
||
Check { key: "header", label: "My data has headers" },
|
||
Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" },
|
||
],
|
||
json!({"range": remove_duplicates_range(app, &sel), "header": true}),
|
||
),
|
||
"advancedFilter" => {
|
||
let list = app
|
||
.session
|
||
.active()
|
||
.and_then(|d| d.wb.active().map(|sh| gridcraft_engine::selection::current_region(sh, d.selection.active).a1()))
|
||
.unwrap_or(sel.clone());
|
||
let mut d = Dialog::form(
|
||
"advancedFilter",
|
||
"Advanced Filter",
|
||
"data.advancedFilter",
|
||
vec![],
|
||
json!({"action": "inPlace", "range": list, "criteria": "", "copyTo": "", "unique": false}),
|
||
);
|
||
for key in ["action", "range", "criteria", "copyTo", "unique"] {
|
||
if let Some(value) = params.get(key) {
|
||
d.set_value(key, value.clone());
|
||
}
|
||
}
|
||
d
|
||
}
|
||
"equation" => return crate::equation::open_editor(app, ¶ms),
|
||
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
|
||
"forecastSheet" => Dialog::form(
|
||
"forecastSheet",
|
||
"Create Forecast Worksheet",
|
||
"data.forecastSheet",
|
||
vec![
|
||
Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" },
|
||
Text { key: "values", label: "Values range (blank: second column of the selection):" },
|
||
Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" },
|
||
Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" },
|
||
Check { key: "confidenceInterval", label: "Confidence interval" },
|
||
Text { key: "confidence", label: "Confidence level (%):" },
|
||
Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" },
|
||
Choice {
|
||
key: "fillMissing",
|
||
label: "Fill missing points using",
|
||
options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")],
|
||
},
|
||
Choice {
|
||
key: "aggregate",
|
||
label: "Aggregate duplicates using",
|
||
options: vec![
|
||
("average", "Average"),
|
||
("count", "Count"),
|
||
("counta", "CountA"),
|
||
("max", "Max"),
|
||
("median", "Median"),
|
||
("min", "Min"),
|
||
("sum", "Sum"),
|
||
],
|
||
},
|
||
Check { key: "statistics", label: "Include forecast statistics" },
|
||
Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] },
|
||
],
|
||
json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}),
|
||
),
|
||
"analysisToolPak" => {
|
||
// Opened with a known `tool`, it goes straight to that tool's form.
|
||
let tool = params.get("tool").and_then(Json::as_str).unwrap_or("");
|
||
let mut d = Dialog::custom("analysisToolPak", "Data Analysis", json!({"tool": "", "pick": tool}));
|
||
if gridcraft_engine::cmd::analysis::TOOLS.iter().any(|(id, _)| *id == tool) {
|
||
d.values = toolpak_defaults(&sel, tool);
|
||
}
|
||
d.command = Some("data.analysisToolPak");
|
||
d
|
||
}
|
||
"solver" => {
|
||
// Starts from the sheet's saved model, else the active cell as objective; fields
|
||
// given when opening it go over either.
|
||
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet()));
|
||
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
|
||
for m in [saved.as_ref(), Some(¶ms)].into_iter().flatten().filter_map(Json::as_object) {
|
||
if let Some(v) = values.as_object_mut() {
|
||
for (k, x) in m {
|
||
v.insert(
|
||
k.clone(),
|
||
if k == "value" { json!(x.as_str().map_or_else(|| x.to_string(), str::to_string)) } else { x.clone() },
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if let Some(v) = values.as_object_mut() {
|
||
v.insert("newOp".into(), json!("<="));
|
||
}
|
||
let options = values.get("optionsView").and_then(Json::as_bool) == Some(true);
|
||
let mut d = Dialog::custom("solver", "Solver Parameters", values);
|
||
d.command = Some("data.solver");
|
||
// Straight into the Solver Options: the same setup as the Options button, so the
|
||
// fields show their defaults and Cancel restores what was there.
|
||
if options {
|
||
analysis::open_solver_options(&mut d);
|
||
}
|
||
d
|
||
}
|
||
"solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),
|
||
"subtotal" => Dialog::form(
|
||
"subtotal",
|
||
"Subtotal",
|
||
"data.subtotal",
|
||
vec![
|
||
Text { key: "groupBy", label: "At each change in (column letter):" },
|
||
Choice {
|
||
key: "function",
|
||
label: "Use function",
|
||
options: vec![
|
||
("sum", "Sum"),
|
||
("count", "Count"),
|
||
("average", "Average"),
|
||
("max", "Max"),
|
||
("min", "Min"),
|
||
("product", "Product"),
|
||
],
|
||
},
|
||
Text { key: "columnsText", label: "Add subtotal to (columns, e.g. C,D):" },
|
||
],
|
||
json!({"groupBy": "A", "function": "sum"}),
|
||
),
|
||
"outlineSettings" => {
|
||
let (below, right) =
|
||
app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true));
|
||
Dialog::form(
|
||
"outlineSettings",
|
||
"Settings",
|
||
"data.outlineSettings",
|
||
vec![
|
||
Check { key: "summaryBelow", label: "Summary rows below detail" },
|
||
Check { key: "summaryRight", label: "Summary columns to right of detail" },
|
||
],
|
||
json!({"summaryBelow": below, "summaryRight": right}),
|
||
)
|
||
}
|
||
"zoom" => {
|
||
// The current zoom is pre-selected (Custom when it matches no preset). Fit
|
||
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
|
||
let zoom = app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100);
|
||
let view = app.grid.cells_rect.unwrap_or(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1200.0, 700.0)));
|
||
Dialog::form(
|
||
"zoom",
|
||
"Zoom",
|
||
"view.zoom",
|
||
vec![],
|
||
json!({"percent": zoom, "preset": zoom_preset(zoom as f64), "viewWidth": view.width(), "viewHeight": view.height()}),
|
||
)
|
||
}
|
||
"defineName" => {
|
||
// Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and
|
||
// its current fields; New… and Define Name start from the selection.
|
||
let editing = params.get("oldName").and_then(Json::as_str).is_some();
|
||
let selection = app.session.active().and_then(|d| {
|
||
d.wb.active().map(|s| format!("={}!{}", gridcraft_engine::formula::quote_sheet(&s.name), abs(&d.selection.current())))
|
||
});
|
||
let mut defaults = json!({"scope": "Workbook", "refersTo": selection});
|
||
for key in ["name", "scope", "comment", "refersTo", "oldName", "oldScope"] {
|
||
if let Some(v) = params.get(key).filter(|v| v.is_string()) {
|
||
defaults[key] = v.clone();
|
||
}
|
||
}
|
||
Dialog::form(
|
||
"defineName",
|
||
if editing { "Edit Name" } else { "New Name" },
|
||
"formulas.defineName",
|
||
vec![
|
||
Text { key: "name", label: "Name:" },
|
||
Text { key: "scope", label: "Scope (Workbook or sheet name):" },
|
||
Text { key: "comment", label: "Comment:" },
|
||
Text { key: "refersTo", label: "Refers to:" },
|
||
],
|
||
defaults,
|
||
)
|
||
}
|
||
"tableResize" => {
|
||
let (table, range) = app
|
||
.session
|
||
.active()
|
||
.and_then(|d| {
|
||
let sh = d.wb.active()?;
|
||
let t = sh.table_at(d.selection.active)?;
|
||
Some((t.name.clone(), t.range.a1()))
|
||
})
|
||
.unwrap_or_default();
|
||
Dialog::form(
|
||
"tableResize",
|
||
"Resize Table",
|
||
"table.resize",
|
||
vec![Text { key: "range", label: "New range for the table:" }],
|
||
json!({"table": table, "range": range}),
|
||
)
|
||
}
|
||
"dataValidation" => Dialog::form(
|
||
"dataValidation",
|
||
"Data Validation",
|
||
"data.validation",
|
||
data_validation_fields("any", "between"),
|
||
validation_defaults(app),
|
||
),
|
||
"cfQuick" => {
|
||
let ty = p("type").as_str().unwrap_or("cellIs").to_string();
|
||
let title = p("title").as_str().unwrap_or("Conditional Formatting").to_string();
|
||
let mut fields = vec![];
|
||
match ty.as_str() {
|
||
"cellIs" => {
|
||
fields.push(Text { key: "value", label: "Format cells that are (value):" });
|
||
if p("operator").as_str() == Some("between") {
|
||
fields.push(Text { key: "value2", label: "and:" });
|
||
}
|
||
}
|
||
"containsText" => fields.push(Text { key: "text", label: "Format cells that contain the text:" }),
|
||
"timePeriod" => fields.push(Choice {
|
||
key: "period",
|
||
label: "Format cells that contain a date occurring:",
|
||
options: vec![
|
||
("yesterday", "Yesterday"),
|
||
("today", "Today"),
|
||
("tomorrow", "Tomorrow"),
|
||
("last7Days", "In the last 7 days"),
|
||
("lastWeek", "Last week"),
|
||
("thisWeek", "This week"),
|
||
("nextWeek", "Next week"),
|
||
("lastMonth", "Last month"),
|
||
("thisMonth", "This month"),
|
||
("nextMonth", "Next month"),
|
||
],
|
||
}),
|
||
_ => fields.push(Text { key: "formula", label: "Format values where this formula is true:" }),
|
||
}
|
||
fields.push(Choice {
|
||
key: "preset",
|
||
label: "with",
|
||
options: vec![
|
||
("lightRedFillDarkRedText", "Light Red Fill with Dark Red Text"),
|
||
("yellowFillDarkYellowText", "Yellow Fill with Dark Yellow Text"),
|
||
("greenFillDarkGreenText", "Green Fill with Dark Green Text"),
|
||
("lightRedFill", "Light Red Fill"),
|
||
("redText", "Red Text"),
|
||
("redBorder", "Red Border"),
|
||
],
|
||
});
|
||
let mut d = Dialog::form(
|
||
"cfQuick",
|
||
&title,
|
||
"home.conditionalFormat",
|
||
fields,
|
||
json!({"preset": "lightRedFillDarkRedText", "period": "today"}),
|
||
);
|
||
if ty == "expression" {
|
||
let (sheet, applies_to) = app
|
||
.session
|
||
.active()
|
||
.map(|doc| {
|
||
(
|
||
doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
|
||
doc.selection.ranges.iter().map(RangeRef::a1).collect::<Vec<_>>().join(","),
|
||
)
|
||
})
|
||
.unwrap_or_default();
|
||
d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to });
|
||
}
|
||
d.values.insert("type".into(), json!(ty));
|
||
d.values.insert("operator".into(), p("operator"));
|
||
d
|
||
}
|
||
"formatCells" => {
|
||
let mut d = Dialog::custom("formatCells", "Format Cells", json!({}));
|
||
d.tab = p("tab").as_str().unwrap_or("Number").to_string();
|
||
if let Ok(st) = app.session.run("cell.get", json!({})) {
|
||
d.values.insert("style".into(), st["style"].clone());
|
||
}
|
||
d
|
||
}
|
||
"insertFunction" => {
|
||
// A Function Library menu opens it on its own category.
|
||
let category = p("category").as_str().unwrap_or(crate::function_library::MOST_RECENTLY_USED).to_string();
|
||
let mut d = Dialog::custom("insertFunction", "Insert Function", json!({"category": category}));
|
||
d.focus_first_field = true;
|
||
d
|
||
}
|
||
"find" => {
|
||
let mut d = Dialog::custom("find", "Find and Replace", json!({"what": "", "with": "", "within": "sheet", "lookIn": "formulas"}));
|
||
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
|
||
d
|
||
}
|
||
"goTo" => {
|
||
let mut dialog = Dialog::custom("goTo", "Go To", json!({"reference": ""}));
|
||
// Listed once, when the dialog opens.
|
||
dialog.result = Some(json!(go_to_entries(app)));
|
||
dialog
|
||
}
|
||
"pivotCalculatedField" => Dialog::form(
|
||
"pivotCalculatedField",
|
||
"Insert Calculated Field",
|
||
"pivot.calculatedField",
|
||
vec![Text { key: "name", label: "Name:" }, Text { key: "formula", label: "Formula:" }],
|
||
json!({"pivot": p("pivot"), "name": p("name"), "formula": p("formula")}),
|
||
),
|
||
"goToSpecial" => Dialog::form(
|
||
"goToSpecial",
|
||
"Go To Special",
|
||
"edit.goToSpecial",
|
||
vec![Choice {
|
||
key: "kind",
|
||
label: "Select",
|
||
options: vec![
|
||
("blanks", "Blanks"),
|
||
("constants", "Constants"),
|
||
("formulas", "Formulas"),
|
||
("notes", "Notes"),
|
||
("errors", "Errors"),
|
||
("numbers", "Numbers"),
|
||
("text", "Text"),
|
||
("currentRegion", "Current region"),
|
||
("lastCell", "Last cell"),
|
||
("visible", "Visible cells only"),
|
||
("conditionalFormats", "Conditional formats"),
|
||
("dataValidation", "Data validation"),
|
||
],
|
||
}],
|
||
json!({"kind": "blanks"}),
|
||
),
|
||
"sort" => {
|
||
// Seed "Sort by" from the column the user is actually in (#1906).
|
||
let col = app.session.active().map(|d| gridcraft_engine::core::col_to_letters(d.selection.active.col)).unwrap_or_else(|| "A".into());
|
||
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
|
||
}
|
||
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
||
"pasteName" => {
|
||
// F3 opens it only when the workbook has names to paste, as in Excel.
|
||
let names = pasteable_names(app);
|
||
if p("shortcut") == json!(true) && names.is_empty() {
|
||
return None;
|
||
}
|
||
// OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too.
|
||
let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()}));
|
||
dialog.command = Some("formulas.pasteName");
|
||
// Listed once, when the dialog opens.
|
||
dialog.result = Some(json!(names));
|
||
dialog
|
||
}
|
||
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
|
||
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
|
||
"agents" => Dialog::custom("agents", "Agent Control", json!({})),
|
||
"about" => {
|
||
let mut d = Dialog::custom("about", "About GridCraft", json!({}));
|
||
d.tab = match p("tab").as_str() {
|
||
Some(t @ ("Contributors" | "Models")) => t.to_string(),
|
||
_ => "About".into(),
|
||
};
|
||
d
|
||
}
|
||
"recommendedCharts" => {
|
||
// Insert › Recommended Charts: chart types for the selected data, with previews.
|
||
let recs = match app.session.run("insert.chartRecommendations", json!({})) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
app.message = Some(("Insert Chart".into(), crate::clean_error(&e)));
|
||
return None;
|
||
}
|
||
};
|
||
let mut d = Dialog::custom("recommendedCharts", "Insert Chart", json!({}));
|
||
d.tab = if p("tab").as_str() == Some("All Charts") { "All Charts".into() } else { "Recommended Charts".into() };
|
||
d.result = Some(recs);
|
||
select_chart(&mut d, 0);
|
||
d
|
||
}
|
||
"selectData" => {
|
||
// Chart Design › Select Data: data range, series to keep, category axis labels.
|
||
let id = p("chart").as_u64();
|
||
let found = app.session.active().and_then(|doc| {
|
||
let si = doc.wb.active_sheet();
|
||
let sh = doc.wb.sheet(si)?;
|
||
let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;
|
||
let data = gridcraft_chart::resolve(&doc.wb, si, ch);
|
||
let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();
|
||
let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();
|
||
Some((ch.source.clone().unwrap_or_default(), cats, names))
|
||
});
|
||
let Some((range, cats, names)) = found else {
|
||
app.message = Some(("Select Data".into(), "Select a chart first.".into()));
|
||
return None;
|
||
};
|
||
let eq = |t: &str| if t.is_empty() { String::new() } else { format!("={t}") };
|
||
Dialog::custom(
|
||
"selectData",
|
||
"Select Data Source",
|
||
json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),
|
||
)
|
||
}
|
||
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
|
||
"macros" => crate::macro_ui::open_dialog(app, ¶ms),
|
||
"recordMacro" => return crate::macro_ui::open_record(app, ¶ms),
|
||
"macroEditor" => return crate::macro_editor::open(app, ¶ms),
|
||
"statistics" => {
|
||
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
|
||
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
|
||
d
|
||
}
|
||
"accessibility" => {
|
||
let mut d = Dialog::custom("accessibility", "Accessibility", json!({}));
|
||
d.result = app.session.run("review.checkAccessibility", json!({})).ok();
|
||
d
|
||
}
|
||
"errorChecking" => {
|
||
let mut d = Dialog::custom("errorChecking", "Error Checking", json!({}));
|
||
d.result = app.session.run("formulas.errorChecking", json!({})).ok();
|
||
d
|
||
}
|
||
"evaluateFormula" => {
|
||
let mut d = Dialog::custom("evaluateFormula", "Evaluate Formula", json!({}));
|
||
d.result = app.session.run("formulas.evaluateFormula", json!({})).ok();
|
||
d
|
||
}
|
||
"spelling" => {
|
||
let mut d = Dialog::custom("spelling", "Spelling", json!({}));
|
||
d.result = Some(app.session.run("review.spelling", json!({})).unwrap_or_else(|e| json!({"error": e})));
|
||
d
|
||
}
|
||
"chartTitle" => Dialog::form(
|
||
"chartTitle",
|
||
"Chart Title",
|
||
"chart.set",
|
||
vec![
|
||
Text { key: "title", label: "Title:" },
|
||
Text { key: "xTitle", label: "Horizontal axis title:" },
|
||
Text { key: "yTitle", label: "Vertical axis title:" },
|
||
],
|
||
json!({"chart": p("chart")}),
|
||
),
|
||
"insertPicture" => {
|
||
let placement = params.get("placement").and_then(Json::as_str).unwrap_or("overCells");
|
||
let mut dialog = Dialog::form(
|
||
"insertPicture",
|
||
if placement == "cell" { "Place Picture in Cell" } else { "Place Picture over Cells" },
|
||
"insert.picture",
|
||
vec![],
|
||
json!({"path": "", "placement": placement, "at": active}),
|
||
);
|
||
dialog.picture_target = app.view_key();
|
||
dialog
|
||
}
|
||
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
|
||
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
|
||
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})),
|
||
"start" => Dialog::custom("start", "GridCraft", json!({})),
|
||
"comments" => {
|
||
app.grid.pane = None;
|
||
app.run_or_alert("review.showComments", json!({"on": true}));
|
||
return None;
|
||
}
|
||
"watch" | "selectionPane" | "formatChart" => {
|
||
app.grid.pane = Some(
|
||
match name {
|
||
"watch" => "watch",
|
||
"selectionPane" => "selection",
|
||
_ => "formatChart",
|
||
}
|
||
.into(),
|
||
);
|
||
return None;
|
||
}
|
||
_ => return None,
|
||
};
|
||
Some(d)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn remove_duplicates_prefills_the_selection_and_submits_the_edited_range() {
|
||
let mut session = gridcraft_engine::Session::default();
|
||
session.new_workbook();
|
||
session.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
|
||
let mut app = SheetApp::new(session, Default::default());
|
||
let mut dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
|
||
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C5")));
|
||
assert!(dialog.fields.iter().any(|field| matches!(field, Field::Text { key: "range", .. })));
|
||
assert_eq!(build_params(&dialog)["range"], "B2:C5");
|
||
dialog.set_value("range", json!("D2:E6"));
|
||
assert_eq!(build_params(&dialog)["range"], "D2:E6");
|
||
}
|
||
|
||
#[test]
|
||
fn remove_duplicates_with_only_the_cursor_prefills_the_current_region() {
|
||
let mut session = gridcraft_engine::Session::default();
|
||
session.new_workbook();
|
||
session.execute("range.setValues", json!({"range": "B2:C4", "values": [["a", 1], ["a", 1], ["b", 2]]})).unwrap();
|
||
session.execute("selection.set", json!({"range": "C3"})).unwrap();
|
||
let mut app = SheetApp::new(session, Default::default());
|
||
let dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
|
||
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C4")));
|
||
}
|
||
|
||
#[test]
|
||
fn data_validation_opens_on_the_rule_of_the_active_cell() {
|
||
let mut session = gridcraft_engine::Session::default();
|
||
session.new_workbook();
|
||
let mut app = SheetApp::new(session, Default::default());
|
||
app.run("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "5"})).unwrap();
|
||
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
|
||
assert_eq!(d.values["type"], "whole");
|
||
assert_eq!(d.values["operator"], "greater");
|
||
assert_eq!(d.values["formula1"], "5");
|
||
// OK without edits keeps the rule: 6 is accepted, 4 is rejected.
|
||
app.run(d.command.unwrap(), build_params(&d)).unwrap();
|
||
let doc = app.session.active().unwrap();
|
||
let at = gridcraft_engine::core::CellRef::new(0, 0);
|
||
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6").is_none());
|
||
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn data_validation_without_a_rule_opens_on_a_blank_list() {
|
||
let mut session = gridcraft_engine::Session::default();
|
||
session.new_workbook();
|
||
let mut app = SheetApp::new(session, Default::default());
|
||
let d = Dialog::open(&mut app, "dataValidation", json!({})).unwrap();
|
||
assert_eq!(d.values["type"], "list");
|
||
assert!(d.values.get("formula1").is_none());
|
||
}
|
||
}
|