2438 lines
111 KiB
Rust
2438 lines
111 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 serde_json::{Map, Value as Json, json};
|
||
|
||
use crate::SheetApp;
|
||
use crate::theme;
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Field {
|
||
Text { 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,
|
||
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>>,
|
||
}
|
||
|
||
impl Dialog {
|
||
pub fn name(&self) -> &str {
|
||
&self.name
|
||
}
|
||
|
||
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,
|
||
picture_inbox: None,
|
||
picture_target: None,
|
||
back: None,
|
||
}
|
||
}
|
||
|
||
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,
|
||
picture_inbox: None,
|
||
picture_target: None,
|
||
back: None,
|
||
}
|
||
}
|
||
|
||
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"}),
|
||
),
|
||
"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: "height", label: "Row height (pixels):" }],
|
||
json!({"height": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row))).unwrap_or(20.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: "formatCells", label: "Format cells" },
|
||
Check { key: "formatColumns", label: "Format columns" },
|
||
Check { key: "formatRows", label: "Format rows" },
|
||
Check { key: "insertRows", label: "Insert rows" },
|
||
Check { key: "deleteRows", label: "Delete rows" },
|
||
Check { key: "sort", label: "Sort" },
|
||
Check { key: "autofilter", label: "Use AutoFilter" },
|
||
],
|
||
json!({}),
|
||
),
|
||
"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}),
|
||
),
|
||
"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": "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}),
|
||
)
|
||
}
|
||
"insertLink" => 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://"}),
|
||
),
|
||
"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:" },
|
||
],
|
||
json!({"header": "", "footer": "&CPage &P of &N"}),
|
||
),
|
||
"pageSetup" => Dialog::form(
|
||
"pageSetup",
|
||
"Page Setup",
|
||
"pageLayout.margins",
|
||
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):" },
|
||
],
|
||
json!({"left": 0.7, "right": 0.7, "top": 0.75, "bottom": 0.75, "header": 0.3, "footer": 0.3}),
|
||
),
|
||
"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![Check { key: "header", label: "My data has headers" }, Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" }],
|
||
json!({"header": true}),
|
||
),
|
||
"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):" },
|
||
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.
|
||
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"});
|
||
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
|
||
for (k, x) in m {
|
||
v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() });
|
||
}
|
||
}
|
||
if let Some(v) = values.as_object_mut() {
|
||
v.insert("newOp".into(), json!("<="));
|
||
}
|
||
let mut d = Dialog::custom("solver", "Solver Parameters", values);
|
||
d.command = Some("data.solver");
|
||
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"}),
|
||
),
|
||
"zoom" => Dialog::form(
|
||
"zoom",
|
||
"Zoom",
|
||
"view.zoom",
|
||
vec![
|
||
Choice {
|
||
key: "preset",
|
||
label: "Magnification",
|
||
options: vec![("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")],
|
||
},
|
||
Number { key: "percent", label: "Custom (%):" },
|
||
],
|
||
json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}),
|
||
),
|
||
"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,
|
||
)
|
||
}
|
||
"dataValidation" => Dialog::form(
|
||
"dataValidation",
|
||
"Data Validation",
|
||
"data.validation",
|
||
vec![
|
||
Choice {
|
||
key: "type",
|
||
label: "Allow",
|
||
options: vec![
|
||
("any", "Any value"),
|
||
("whole", "Whole number"),
|
||
("decimal", "Decimal"),
|
||
("list", "List"),
|
||
("date", "Date"),
|
||
("time", "Time"),
|
||
("textLength", "Text length"),
|
||
("custom", "Custom"),
|
||
],
|
||
},
|
||
Choice {
|
||
key: "operator",
|
||
label: "Data",
|
||
options: vec![
|
||
("between", "between"),
|
||
("notBetween", "not between"),
|
||
("equal", "equal to"),
|
||
("notEqual", "not equal to"),
|
||
("greater", "greater than"),
|
||
("less", "less than"),
|
||
("greaterOrEqual", "greater than or equal to"),
|
||
("lessOrEqual", "less than or equal to"),
|
||
],
|
||
},
|
||
Text { key: "formula1", label: "Minimum / Source / Formula:" },
|
||
Text { key: "formula2", label: "Maximum:" },
|
||
Check { key: "ignoreBlank", label: "Ignore blank" },
|
||
Check { key: "dropdown", label: "In-cell dropdown" },
|
||
Text { key: "inputMessage", label: "Input message:" },
|
||
Choice {
|
||
key: "errorStyle",
|
||
label: "Error style",
|
||
options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")],
|
||
},
|
||
Text { key: "errorMessage", label: "Error message:" },
|
||
],
|
||
json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}),
|
||
),
|
||
"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"}),
|
||
);
|
||
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" => Dialog::custom("insertFunction", "Formula Builder", json!({"category": "Most Recently Used"})),
|
||
"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" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
|
||
"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" => Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": "A", "order": "asc"}]})),
|
||
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
||
"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" => {
|
||
let mut d = Dialog::custom("macros", "Macros", json!({}));
|
||
d.result = app.session.run("view.macros", json!({})).ok();
|
||
d
|
||
}
|
||
"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")})),
|
||
"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)
|
||
}
|
||
}
|
||
|
||
fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
|
||
let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1);
|
||
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
|
||
}
|
||
|
||
/// Builds the command params from form values for dialogs that need translation.
|
||
fn build_params(d: &Dialog) -> Json {
|
||
let mut v = Json::Object(d.values.clone());
|
||
match d.name.as_str() {
|
||
"textToColumns" => {
|
||
let mut delims = Vec::new();
|
||
for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] {
|
||
if d.values.get(k).and_then(Json::as_bool) == Some(true) {
|
||
delims.push(json!(c));
|
||
}
|
||
}
|
||
v["delimiters"] = Json::Array(delims);
|
||
}
|
||
"removeDuplicates" | "subtotal" => {
|
||
if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) {
|
||
v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect());
|
||
}
|
||
}
|
||
"allowEditRanges" => {
|
||
if let Some(m) = v.as_object_mut() {
|
||
// Blank fields mean "unchanged"; the check box clears a password.
|
||
m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty()));
|
||
if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) {
|
||
m.insert("password".into(), json!(""));
|
||
}
|
||
}
|
||
}
|
||
"zoom" => {
|
||
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
|
||
&& d.values.get("percent").is_none()
|
||
{
|
||
v["percent"] = json!(p.parse::<f64>().unwrap_or(100.0));
|
||
}
|
||
}
|
||
"cfQuick" => {
|
||
let mut rule = Map::new();
|
||
for (k, val) in &d.values {
|
||
rule.insert(k.clone(), val.clone());
|
||
}
|
||
v = json!({"rule": Json::Object(rule)});
|
||
}
|
||
"series" => {
|
||
if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::<f64>().ok()) {
|
||
v["stop"] = json!(s);
|
||
} else if let Some(m) = v.as_object_mut() {
|
||
m.remove("stop");
|
||
}
|
||
}
|
||
"defineName" => {
|
||
if let Some(m) = v.as_object_mut() {
|
||
for key in ["scope", "oldScope"] {
|
||
if m.get(key).and_then(Json::as_str) == Some("Workbook") {
|
||
m.remove(key);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
v
|
||
}
|
||
|
||
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||
let Some(mut d) = app.dialog.take() else { return };
|
||
if d.name == "pasteSpecial" {
|
||
paste_special_shortcuts(&mut d, ctx);
|
||
}
|
||
let mut open = true;
|
||
let mut win_open = true;
|
||
let mut confirm = false;
|
||
let width = match d.name.as_str() {
|
||
"formatCells" => 560.0,
|
||
"about" => 660.0,
|
||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
|
||
"recommendedCharts" => 600.0,
|
||
"analysisToolPak" => 420.0,
|
||
"solver" => 460.0,
|
||
_ => 380.0,
|
||
};
|
||
let window = egui::Window::new(d.title.clone())
|
||
.id(egui::Id::new("dialog").with(&d.name))
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.default_width(width)
|
||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
|
||
.open(&mut win_open);
|
||
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
|
||
window.show(ctx, |ui| {
|
||
ui.set_min_width(width - 20.0);
|
||
match d.name.as_str() {
|
||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
|
||
"find" => find(app, ui, &mut d),
|
||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||
"sort" => sort(app, ui, &mut d, &mut confirm),
|
||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||
"manageRules" => manage_rules(app, ui),
|
||
"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),
|
||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
|
||
"solverResults" => solver_results(app, ui, &mut d, &mut open),
|
||
"spelling" => spelling(app, ui, &mut d, &mut open),
|
||
"agents" => {
|
||
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
|
||
ui.add_space(4.0);
|
||
ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server");
|
||
ui.add_space(4.0);
|
||
ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len()));
|
||
}
|
||
"about" => about(ui, &mut d),
|
||
"journal" => {
|
||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||
for (id, p) in app.session.journal.iter().rev().take(500) {
|
||
ui.monospace(format!("{id} {p}"));
|
||
}
|
||
});
|
||
}
|
||
"macros" => macros(ui, &mut d),
|
||
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
|
||
let r = if d.name == "comments" {
|
||
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
|
||
} else {
|
||
d.result.clone()
|
||
};
|
||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||
ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default());
|
||
});
|
||
}
|
||
"insertPicture" => {
|
||
// A private inbox dies with this dialog; late browser results
|
||
// cannot affect a later picture choice or another workbook.
|
||
let arrived = d.picture_inbox.as_ref().and_then(|inbox| {
|
||
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop()
|
||
});
|
||
if let Some((name, bytes)) = arrived {
|
||
d.picture_inbox = None;
|
||
if bytes.len() > 16 * 1024 * 1024 {
|
||
app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into()));
|
||
} else {
|
||
d.values.remove("path");
|
||
d.values.insert("alt".into(), json!(name));
|
||
d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes)));
|
||
}
|
||
}
|
||
if app.services.pick_picture_async.is_some() {
|
||
let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected");
|
||
ui.label(name);
|
||
} else {
|
||
ui.label("Picture file path (PNG or JPEG):");
|
||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||
if ui.text_edit_singleline(&mut path).changed() {
|
||
d.values.remove("base64");
|
||
d.values.insert("path".into(), json!(path));
|
||
}
|
||
}
|
||
if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() {
|
||
if let Some(start) = &app.services.pick_picture_async {
|
||
let inbox = crate::Inbox::default();
|
||
start(inbox.clone());
|
||
d.picture_inbox = Some(inbox);
|
||
} else if let Some(pick) = &app.services.pick_picture
|
||
&& let Some(path) = pick()
|
||
{
|
||
d.values.remove("base64");
|
||
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||
d.values.insert("alt".into(), json!(alt));
|
||
d.values.insert("path".into(), json!(path));
|
||
}
|
||
}
|
||
ui.label("Description (optional):");
|
||
let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string();
|
||
if ui.text_edit_singleline(&mut alt).changed() {
|
||
d.values.insert("alt".into(), json!(alt));
|
||
}
|
||
if d.values.get("placement").and_then(Json::as_str) == Some("cell") {
|
||
ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small());
|
||
}
|
||
ok_cancel(ui, &mut confirm, &mut open);
|
||
}
|
||
"pickList" => {
|
||
let items: Vec<String> = app
|
||
.session
|
||
.active()
|
||
.and_then(|doc| {
|
||
let sh = doc.wb.active()?;
|
||
let a = doc.selection.active;
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
let mut r = a.row;
|
||
while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() {
|
||
r -= 1;
|
||
seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display());
|
||
}
|
||
Some(seen.into_iter().collect())
|
||
})
|
||
.unwrap_or_default();
|
||
for it in items {
|
||
if ui.button(&it).clicked() {
|
||
app.run_or_alert("cell.set", json!({"input": it}));
|
||
open = false;
|
||
}
|
||
}
|
||
}
|
||
"saveCopy" => {
|
||
let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string();
|
||
ui.label(format!("Save a copy as .{fmt}"));
|
||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||
ui.text_edit_singleline(&mut path);
|
||
d.values.insert("path".into(), json!(path.clone()));
|
||
ok_cancel(ui, &mut confirm, &mut open);
|
||
if confirm {
|
||
let target = if path.is_empty() { app.services.pick_save.as_ref().and_then(|f| f(&format!("Copy.{fmt}"))) } else { Some(path) };
|
||
if let Some(t) = target {
|
||
let id = match fmt.as_str() {
|
||
"csv" => "file.exportCsv",
|
||
"html" => "file.exportHtml",
|
||
_ => "file.saveAs",
|
||
};
|
||
app.run_or_alert(id, json!({"path": t}));
|
||
}
|
||
}
|
||
}
|
||
"saveChanges" => {
|
||
ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook")));
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Don't Save").clicked() {
|
||
app.run_or_alert("file.close", json!({"force": true}));
|
||
open = false;
|
||
}
|
||
if ui.button("Cancel").clicked() {
|
||
open = false;
|
||
}
|
||
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
|
||
if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) {
|
||
app.run_or_alert("file.save", json!({}));
|
||
if app.session.active().is_some_and(|x| !x.is_dirty()) {
|
||
app.run_or_alert("file.close", json!({"force": true}));
|
||
}
|
||
open = false;
|
||
}
|
||
});
|
||
}
|
||
"start" => {
|
||
ui.heading("Start a new workbook");
|
||
ui.horizontal_wrapped(|ui| {
|
||
if ui.button("Blank workbook").clicked() {
|
||
app.run_or_alert("file.new", json!({}));
|
||
open = false;
|
||
}
|
||
for (n, t) in gridcraft_engine::sample::SAMPLES {
|
||
if ui.button(*t).clicked() {
|
||
app.run_or_alert("file.new", json!({"sample": n}));
|
||
open = false;
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
ui.label(egui::RichText::new("Recent").strong());
|
||
for path in app.ui.recent.clone() {
|
||
if ui.link(&path).clicked() {
|
||
app.open_path(&path);
|
||
open = false;
|
||
}
|
||
}
|
||
if ui.button("Open…").clicked() {
|
||
app.open_dialog("open", json!({}));
|
||
open = false;
|
||
}
|
||
}
|
||
_ => form(ui, &mut d, &mut confirm, &mut open),
|
||
}
|
||
});
|
||
// 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 {
|
||
let urls: Vec<String> = ctx.output_mut(|o| {
|
||
let mut urls = vec![];
|
||
o.commands.retain(|c| match c {
|
||
egui::OutputCommand::OpenUrl(u) => {
|
||
urls.push(u.url.clone());
|
||
false
|
||
}
|
||
_ => true,
|
||
});
|
||
urls
|
||
});
|
||
for u in urls {
|
||
open_url(&u);
|
||
}
|
||
}
|
||
if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open {
|
||
open = false;
|
||
}
|
||
if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() {
|
||
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 && let Some(cmd) = d.command {
|
||
let params = build_params(&d);
|
||
match app.run(cmd, params) {
|
||
Ok(r) => {
|
||
open = false;
|
||
if d.name == "solver" {
|
||
app.open_dialog("solverResults", r);
|
||
}
|
||
}
|
||
// Data Analysis asks before replacing data in its output range.
|
||
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
|
||
d.values.insert("askOverwrite".into(), json!(true));
|
||
}
|
||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||
}
|
||
} else if confirm {
|
||
open = false;
|
||
}
|
||
if let Some(child) = app.dialog.as_mut() {
|
||
// The body opened another dialog (Name Manager › New…). It takes over; this one waits
|
||
// behind it and comes back when it closes, as Excel returns to the Name Manager. Go To
|
||
// hands over to Go To Special for good, as in Excel.
|
||
if open && d.name != "goTo" {
|
||
child.back = Some(Box::new(d));
|
||
} else if child.back.is_none() {
|
||
child.back = d.back.take();
|
||
}
|
||
return;
|
||
}
|
||
if open {
|
||
app.dialog = Some(d);
|
||
} else if let Some(back) = d.back.take() {
|
||
app.dialog = Some(*back);
|
||
}
|
||
}
|
||
|
||
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
|
||
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||
if ctx.text_edit_focused() {
|
||
return;
|
||
}
|
||
let right_alt = ctx.input(|i| {
|
||
i.key_down(Key::AltRight)
|
||
|| i.events.iter().any(|event| {
|
||
matches!(event, egui::Event::Key { key: Key::AltRight, .. })
|
||
|| matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. })
|
||
})
|
||
});
|
||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
|
||
let mnemonic = |key| match key {
|
||
Key::T => Some('t'),
|
||
Key::V => Some('v'),
|
||
Key::F => Some('f'),
|
||
Key::C => Some('c'),
|
||
Key::E => Some('e'),
|
||
_ => None,
|
||
};
|
||
let mut text_to_consume = Vec::new();
|
||
for event in ctx.input(|i| i.events.clone()) {
|
||
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
|
||
if !allowed(modifiers) {
|
||
continue;
|
||
}
|
||
let Some(letter) = mnemonic(key) else { continue };
|
||
text_to_consume.push(letter);
|
||
if repeat {
|
||
continue;
|
||
}
|
||
if key == Key::E {
|
||
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
|
||
d.values.insert("transpose".into(), json!(!transpose));
|
||
} else {
|
||
let what = match key {
|
||
Key::T => "formats",
|
||
Key::V => "values",
|
||
Key::F => "formulas",
|
||
_ => "comments",
|
||
};
|
||
d.values.insert("what".into(), json!(what));
|
||
}
|
||
}
|
||
// Browsers can deliver Text before or after Key. Act only on Key, then remove
|
||
// its matching text so one physical press cannot toggle Transpose twice.
|
||
ctx.input_mut(|i| {
|
||
i.events.retain(|event| match event {
|
||
egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()),
|
||
egui::Event::Text(text) if text.len() == 1 => {
|
||
let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase();
|
||
if let Some(index) = text_to_consume.iter().position(|&c| c == letter) {
|
||
text_to_consume.remove(index);
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
}
|
||
_ => true,
|
||
});
|
||
});
|
||
}
|
||
|
||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||
ui.add_space(8.0);
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||
*confirm = true;
|
||
}
|
||
if ui.button(" Cancel ").clicked() {
|
||
*open = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||
form_fields(ui, d);
|
||
ok_cancel(ui, confirm, open);
|
||
}
|
||
|
||
/// Draws a dialog's `fields`, editing its values.
|
||
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||
for f in d.fields.clone() {
|
||
match f {
|
||
Field::Text { key, label } => {
|
||
ui.vertical(|ui| {
|
||
ui.label(label);
|
||
let mut s = d
|
||
.values
|
||
.get(key)
|
||
.map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() }))
|
||
.unwrap_or_default();
|
||
let multiline = key == "text";
|
||
let r = if multiline {
|
||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0))
|
||
} else {
|
||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0))
|
||
};
|
||
if r.changed() {
|
||
d.values.insert(key.into(), json!(s));
|
||
}
|
||
});
|
||
}
|
||
Field::Password { key, label } => {
|
||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||
ui.vertical(|ui| {
|
||
ui.label(label);
|
||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
|
||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
|
||
d.values.insert(key.into(), json!(s));
|
||
}
|
||
ui.checkbox(&mut d.show_password, "Show password");
|
||
});
|
||
}
|
||
Field::Number { key, label } => {
|
||
ui.horizontal(|ui| {
|
||
ui.label(label);
|
||
let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0);
|
||
if ui.add(egui::DragValue::new(&mut n).speed(0.5)).changed() {
|
||
d.values.insert(key.into(), json!(n));
|
||
}
|
||
});
|
||
}
|
||
Field::Choice { key, label, options } => {
|
||
ui.vertical(|ui| {
|
||
ui.label(egui::RichText::new(label).strong());
|
||
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||
for (val, text) in options {
|
||
if ui.radio(cur == val, text).clicked() {
|
||
d.values.insert(key.into(), json!(val));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
Field::Check { key, label } => {
|
||
let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
|
||
if ui.checkbox(&mut b, label).changed() {
|
||
d.values.insert(key.into(), json!(b));
|
||
}
|
||
}
|
||
Field::Note(t) => {
|
||
ui.label(egui::RichText::new(t).small());
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits
|
||
/// are compiled in (`crate::credits`, docs/contributors.md).
|
||
fn about(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
ui.horizontal(|ui| {
|
||
for tab in ["About", "Contributors", "Models"] {
|
||
if ui.selectable_label(d.tab == tab, tab).clicked() {
|
||
d.tab = tab.to_string();
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
match d.tab.as_str() {
|
||
"Contributors" => crate::credits::contributors_ui(ui),
|
||
"Models" => crate::credits::models_ui(ui),
|
||
_ => {
|
||
ui.heading("GridCraft");
|
||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||
ui.add_space(4.0);
|
||
ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft.");
|
||
ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft");
|
||
ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
|
||
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
|
||
/// preserved and not executed.
|
||
fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
|
||
ui.add_space(6.0);
|
||
let items: Vec<Json> = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
|
||
if items.is_empty() {
|
||
ui.weak("This workbook has no scripts or macros.");
|
||
return;
|
||
}
|
||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||
for it in &items {
|
||
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
|
||
let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba");
|
||
ui.horizontal(|ui| {
|
||
ui.label(egui::RichText::new(name).strong());
|
||
if is_vba {
|
||
ui.weak("— VBA project (preserved, not run)");
|
||
} else {
|
||
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
|
||
ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" }));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||
ui.horizontal(|ui| {
|
||
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {
|
||
if ui.selectable_label(d.tab == tab, tab).clicked() {
|
||
d.tab = tab.to_string();
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||
let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default();
|
||
match d.tab.as_str() {
|
||
"Number" => {
|
||
let mut code = st.num_fmt.as_str().to_string();
|
||
ui.columns(2, |cols| {
|
||
cols[0].label(egui::RichText::new("Category:").strong());
|
||
for name in
|
||
["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]
|
||
{
|
||
let c = gridcraft_engine::cmd::format::format_code_for(name).to_string();
|
||
if cols[0].selectable_label(code == c, name).clicked() {
|
||
code = c;
|
||
}
|
||
}
|
||
cols[1].label(egui::RichText::new("Sample").strong());
|
||
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
|
||
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
|
||
cols[1].add_space(8.0);
|
||
cols[1].label("Type (custom format code):");
|
||
cols[1].text_edit_singleline(&mut code);
|
||
for c in [
|
||
"0",
|
||
"0.00",
|
||
"#,##0",
|
||
"#,##0.00",
|
||
"#,##0;[Red]-#,##0",
|
||
"0%",
|
||
"0.00%",
|
||
"0.00E+00",
|
||
"# ?/?",
|
||
"m/d/yyyy",
|
||
"d-mmm-yy",
|
||
"mmmm d, yyyy",
|
||
"h:mm AM/PM",
|
||
"h:mm:ss",
|
||
"[h]:mm:ss",
|
||
"@",
|
||
] {
|
||
if cols[1].small_button(c).clicked() {
|
||
code = c.to_string();
|
||
}
|
||
}
|
||
});
|
||
st.num_fmt = gridcraft_engine::model::NumFmt::new(&code);
|
||
}
|
||
"Alignment" => {
|
||
use gridcraft_engine::model::{HAlign, VAlign};
|
||
ui.label(egui::RichText::new("Horizontal").strong());
|
||
ui.horizontal_wrapped(|ui| {
|
||
for (h, n) in [
|
||
(HAlign::General, "General"),
|
||
(HAlign::Left, "Left"),
|
||
(HAlign::Center, "Center"),
|
||
(HAlign::Right, "Right"),
|
||
(HAlign::Fill, "Fill"),
|
||
(HAlign::Justify, "Justify"),
|
||
(HAlign::CenterAcross, "Center Across Selection"),
|
||
(HAlign::Distributed, "Distributed"),
|
||
] {
|
||
ui.radio_value(&mut st.align.h, h, n);
|
||
}
|
||
});
|
||
ui.label(egui::RichText::new("Vertical").strong());
|
||
ui.horizontal_wrapped(|ui| {
|
||
for (v, n) in [
|
||
(VAlign::Top, "Top"),
|
||
(VAlign::Center, "Center"),
|
||
(VAlign::Bottom, "Bottom"),
|
||
(VAlign::Justify, "Justify"),
|
||
(VAlign::Distributed, "Distributed"),
|
||
] {
|
||
ui.radio_value(&mut st.align.v, v, n);
|
||
}
|
||
});
|
||
let mut indent = st.align.indent as f32;
|
||
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent"));
|
||
st.align.indent = indent as u8;
|
||
let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 };
|
||
ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)"));
|
||
st.align.rotation = rot as i16;
|
||
ui.checkbox(&mut st.align.wrap, "Wrap text");
|
||
ui.checkbox(&mut st.align.shrink, "Shrink to fit");
|
||
}
|
||
"Font" => {
|
||
egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| {
|
||
for f in crate::fonts::families() {
|
||
ui.selectable_value(&mut st.font.name, f.clone(), f.clone());
|
||
}
|
||
});
|
||
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size"));
|
||
ui.horizontal(|ui| {
|
||
ui.checkbox(&mut st.font.bold, "Bold");
|
||
ui.checkbox(&mut st.font.italic, "Italic");
|
||
ui.checkbox(&mut st.font.strike, "Strikethrough");
|
||
});
|
||
use gridcraft_engine::model::{Underline, VertAlign};
|
||
ui.horizontal(|ui| {
|
||
for (u, n) in [
|
||
(Underline::None, "None"),
|
||
(Underline::Single, "Single"),
|
||
(Underline::Double, "Double"),
|
||
(Underline::SingleAccounting, "Single Accounting"),
|
||
(Underline::DoubleAccounting, "Double Accounting"),
|
||
] {
|
||
ui.radio_value(&mut st.font.underline, u, n);
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal");
|
||
ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript");
|
||
ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript");
|
||
});
|
||
ui.label("Color:");
|
||
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
|
||
if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") {
|
||
st.font.color =
|
||
if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() };
|
||
}
|
||
}
|
||
"Border" => {
|
||
use gridcraft_engine::model::{BorderLine, BorderStyle};
|
||
let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto };
|
||
ui.horizontal(|ui| {
|
||
if ui.button("None").clicked() {
|
||
st.borders = Default::default();
|
||
}
|
||
if ui.button("Outline").clicked() {
|
||
st.borders.top = line;
|
||
st.borders.bottom = line;
|
||
st.borders.left = line;
|
||
st.borders.right = line;
|
||
}
|
||
});
|
||
for (n, slot) in [
|
||
("Top", &mut st.borders.top),
|
||
("Bottom", &mut st.borders.bottom),
|
||
("Left", &mut st.borders.left),
|
||
("Right", &mut st.borders.right),
|
||
("Diagonal ↘", &mut st.borders.diag_down),
|
||
("Diagonal ↗", &mut st.borders.diag_up),
|
||
] {
|
||
ui.horizontal(|ui| {
|
||
ui.label(format!("{n}:"));
|
||
for (s, label) in [
|
||
(BorderStyle::None, "none"),
|
||
(BorderStyle::Thin, "thin"),
|
||
(BorderStyle::Medium, "medium"),
|
||
(BorderStyle::Thick, "thick"),
|
||
(BorderStyle::Dashed, "dashed"),
|
||
(BorderStyle::Dotted, "dotted"),
|
||
(BorderStyle::Double, "double"),
|
||
] {
|
||
let response = ui.selectable_label(slot.style == s, label);
|
||
response
|
||
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
|
||
if response.clicked() {
|
||
slot.style = s;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default();
|
||
crate::border_preview::sample(ui, &st.borders, &theme);
|
||
}
|
||
"Fill" => {
|
||
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
|
||
ui.label("Background Color:");
|
||
if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") {
|
||
st.fill = if c == "none" {
|
||
Default::default()
|
||
} else {
|
||
gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default())
|
||
};
|
||
}
|
||
}
|
||
"Protection" => {
|
||
ui.checkbox(&mut st.protection.locked, "Locked");
|
||
ui.checkbox(&mut st.protection.hidden, "Hidden");
|
||
ui.label(
|
||
egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).")
|
||
.small(),
|
||
);
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Ok(v) = serde_json::to_value(&st) {
|
||
d.values.insert("style".into(), v);
|
||
}
|
||
let mut open = true;
|
||
ok_cancel(ui, confirm, &mut open);
|
||
if *confirm && let Some(style) = d.values.get("style").cloned() {
|
||
app.run_or_alert("home.formatCells", json!({"style": style}));
|
||
}
|
||
if !open {
|
||
app.dialog = None;
|
||
*confirm = true; // closes
|
||
}
|
||
}
|
||
|
||
fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Search:");
|
||
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date"));
|
||
r.request_focus();
|
||
});
|
||
let locale = app.ui.language.formula_locale();
|
||
let list = gridcraft_engine::cmd::formulas::function_list();
|
||
let q = d.search.to_ascii_lowercase();
|
||
let hits: Vec<&Json> = list
|
||
.iter()
|
||
.filter(|f| {
|
||
q.is_empty()
|
||
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q))
|
||
|| f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q))
|
||
})
|
||
.take(300)
|
||
.collect();
|
||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||
for (i, f) in hits.iter().enumerate() {
|
||
let name = locale.function_name(f["name"].as_str().unwrap_or(""));
|
||
let r = ui.add(
|
||
egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or("")))
|
||
.min_size(vec2(480.0, 20.0)),
|
||
);
|
||
if r.clicked() {
|
||
d.list_index = i;
|
||
}
|
||
if r.double_clicked() {
|
||
d.list_index = i;
|
||
*confirm = true;
|
||
}
|
||
}
|
||
});
|
||
if let Some(f) = hits.get(d.list_index) {
|
||
ui.separator();
|
||
ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong());
|
||
ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default());
|
||
}
|
||
let mut open = true;
|
||
ok_cancel(ui, confirm, &mut open);
|
||
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
|
||
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n);
|
||
let cur = app.editor.as_ref().map(|e| e.text.clone());
|
||
let text = match cur {
|
||
Some(t) if t.starts_with('=') => format!("{t}{n}("),
|
||
_ => format!("={n}("),
|
||
};
|
||
app.begin_edit(Some(text), false);
|
||
}
|
||
if !open {
|
||
*confirm = true;
|
||
}
|
||
}
|
||
|
||
fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
ui.horizontal(|ui| {
|
||
for tab in ["Find", "Replace"] {
|
||
if ui.selectable_label(d.tab == tab, tab).clicked() {
|
||
d.tab = tab.into();
|
||
}
|
||
}
|
||
});
|
||
let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string();
|
||
let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string();
|
||
ui.horizontal(|ui| {
|
||
ui.label("Find what:");
|
||
ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0));
|
||
});
|
||
if d.tab == "Replace" {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Replace with:");
|
||
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0));
|
||
});
|
||
}
|
||
let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false);
|
||
let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false);
|
||
let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook");
|
||
ui.horizontal(|ui| {
|
||
ui.checkbox(&mut case, "Match case");
|
||
ui.checkbox(&mut whole, "Match entire cell contents");
|
||
ui.checkbox(&mut wb, "Within workbook");
|
||
});
|
||
d.values.insert("what".into(), json!(what));
|
||
d.values.insert("with".into(), json!(with));
|
||
d.values.insert("matchCase".into(), json!(case));
|
||
d.values.insert("wholeCell".into(), json!(whole));
|
||
d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" }));
|
||
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }});
|
||
ui.horizontal(|ui| {
|
||
if d.tab == "Replace" {
|
||
if ui.button("Replace All").clicked() {
|
||
let mut p = base.clone();
|
||
p["with"] = json!(with);
|
||
p["all"] = json!(true);
|
||
match app.run("edit.replace", p) {
|
||
Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())),
|
||
Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))),
|
||
}
|
||
}
|
||
if ui.button("Replace").clicked() {
|
||
let mut p = base.clone();
|
||
p["with"] = json!(with);
|
||
p["all"] = json!(false);
|
||
let _ = app.run("edit.replace", p);
|
||
let _ = app.run("edit.find", base.clone());
|
||
}
|
||
}
|
||
if ui.button("Find All").clicked() {
|
||
let mut p = base.clone();
|
||
p["all"] = json!(true);
|
||
d.result = app.run("edit.find", p).ok();
|
||
}
|
||
if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||
if let Err(e) = app.run("edit.find", base.clone()) {
|
||
app.message = Some(("Find and Replace".into(), crate::clean_error(&e)));
|
||
}
|
||
app.grid.ensure_visible = true;
|
||
}
|
||
});
|
||
if let Some(r) = &d.result
|
||
&& let Some(list) = r["results"].as_array()
|
||
{
|
||
ui.separator();
|
||
ui.label(format!("{} cell(s) found", list.len()));
|
||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||
for item in list {
|
||
let label = format!(
|
||
"{} {} {}",
|
||
item["sheet"].as_str().unwrap_or(""),
|
||
item["cell"].as_str().unwrap_or(""),
|
||
item["value"].as_str().unwrap_or("")
|
||
);
|
||
if ui.selectable_label(false, label).clicked() {
|
||
if let Some(s) = item["sheet"].as_str() {
|
||
let _ = app.run("sheet.activate", json!({"sheet": s}));
|
||
}
|
||
let _ = app.run("selection.set", json!({"cell": item["cell"]}));
|
||
app.grid.ensure_visible = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||
for n in names {
|
||
if ui.selectable_label(false, &n).clicked() {
|
||
d.values.insert("reference".into(), json!(n));
|
||
}
|
||
}
|
||
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||
ui.horizontal(|ui| {
|
||
ui.label("Reference:");
|
||
ui.text_edit_singleline(&mut r).request_focus();
|
||
});
|
||
d.values.insert("reference".into(), json!(r.clone()));
|
||
if ui.button("Special…").clicked() {
|
||
app.open_dialog("goToSpecial", json!({}));
|
||
return;
|
||
}
|
||
let mut open = true;
|
||
ok_cancel(ui, confirm, &mut open);
|
||
if *confirm {
|
||
app.run_or_alert("edit.goTo", json!({"reference": r}));
|
||
app.grid.ensure_visible = true;
|
||
}
|
||
if !open {
|
||
*confirm = true;
|
||
}
|
||
}
|
||
|
||
fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||
let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true);
|
||
ui.checkbox(&mut header, "My list has headers");
|
||
d.values.insert("header".into(), json!(header));
|
||
let mut levels: Vec<Json> = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default();
|
||
let mut remove = None;
|
||
for (i, lv) in levels.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
ui.label(if i == 0 { "Sort by" } else { "Then by" });
|
||
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
|
||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
|
||
lv["column"] = json!(col.to_ascii_uppercase());
|
||
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
|
||
egui::ComboBox::from_id_salt(("by", i))
|
||
.selected_text(match by.as_str() {
|
||
"cellColor" => "Cell Color",
|
||
"fontColor" => "Font Color",
|
||
_ => "Cell Values",
|
||
})
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut by, "values".into(), "Cell Values");
|
||
ui.selectable_value(&mut by, "cellColor".into(), "Cell Color");
|
||
ui.selectable_value(&mut by, "fontColor".into(), "Font Color");
|
||
});
|
||
lv["by"] = json!(by);
|
||
let mut desc = lv["order"].as_str() == Some("desc");
|
||
ui.radio_value(&mut desc, false, "A to Z");
|
||
ui.radio_value(&mut desc, true, "Z to A");
|
||
lv["order"] = json!(if desc { "desc" } else { "asc" });
|
||
if ui.small_button("✕").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove
|
||
&& levels.len() > 1
|
||
{
|
||
levels.remove(i);
|
||
}
|
||
if ui.button("+ Add Level").clicked() {
|
||
levels.push(json!({"column": "B", "order": "asc"}));
|
||
}
|
||
d.values.insert("levels".into(), Json::Array(levels.clone()));
|
||
let mut open = true;
|
||
ok_cancel(ui, confirm, &mut open);
|
||
if *confirm {
|
||
app.run_or_alert("data.sort", json!({"header": header, "keys": levels}));
|
||
}
|
||
if !open {
|
||
*confirm = true;
|
||
}
|
||
}
|
||
|
||
fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||
let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null);
|
||
let names = list.as_array().cloned().unwrap_or_default();
|
||
let selected = names.get(d.list_index).cloned();
|
||
let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||
ui.horizontal(|ui| {
|
||
if ui.button("New…").clicked() {
|
||
app.open_dialog("defineName", json!({}));
|
||
}
|
||
if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked()
|
||
&& let Some(n) = &selected
|
||
{
|
||
app.open_dialog(
|
||
"defineName",
|
||
json!({
|
||
"name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"),
|
||
"refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"),
|
||
}),
|
||
);
|
||
}
|
||
if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked()
|
||
&& let Some(n) = &selected
|
||
{
|
||
app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")}));
|
||
}
|
||
});
|
||
ui.add_space(4.0);
|
||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||
egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| {
|
||
ui.strong("Name");
|
||
ui.strong("Value");
|
||
ui.strong("Refers To");
|
||
ui.strong("Scope");
|
||
ui.end_row();
|
||
if names.is_empty() {
|
||
ui.label(egui::RichText::new("No names defined").italics());
|
||
ui.end_row();
|
||
}
|
||
for (i, n) in names.iter().enumerate() {
|
||
if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() {
|
||
d.list_index = i;
|
||
}
|
||
ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::<String>());
|
||
ui.label(field(n, "refersTo"));
|
||
ui.label(field(n, "scope"));
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.button(" Close ").clicked() {
|
||
*open = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
|
||
const ALL_CHARTS: [(&str, &str, &str); 20] = [
|
||
("column", "", "Clustered Column"),
|
||
("column", "stacked", "Stacked Column"),
|
||
("column", "stacked100", "100% Stacked Column"),
|
||
("bar", "", "Clustered Bar"),
|
||
("bar", "stacked", "Stacked Bar"),
|
||
("bar", "stacked100", "100% Stacked Bar"),
|
||
("line", "", "Line"),
|
||
("line", "markers", "Line with Markers"),
|
||
("line", "stacked", "Stacked Line"),
|
||
("pie", "", "Pie"),
|
||
("doughnut", "", "Doughnut"),
|
||
("area", "", "Area"),
|
||
("area", "stacked", "Stacked Area"),
|
||
("scatter", "", "Scatter"),
|
||
("scatter", "lines", "Scatter with Lines"),
|
||
("radar", "", "Radar"),
|
||
("histogram", "", "Histogram"),
|
||
("waterfall", "", "Waterfall"),
|
||
("funnel", "", "Funnel"),
|
||
("combo", "", "Combo"),
|
||
];
|
||
|
||
/// The chart types listed on the dialog's current tab.
|
||
fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> {
|
||
if d.tab == "All Charts" {
|
||
return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect();
|
||
}
|
||
let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||
d.result
|
||
.as_ref()
|
||
.and_then(Json::as_array)
|
||
.map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Selects entry `i` of the current tab: OK inserts it.
|
||
fn select_chart(d: &mut Dialog, i: usize) {
|
||
let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return };
|
||
d.list_index = i;
|
||
d.values.insert("type".into(), json!(t));
|
||
d.values.insert("subtype".into(), json!(sub));
|
||
}
|
||
|
||
fn chart_icon(t: &str) -> Icon {
|
||
match t {
|
||
"column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar,
|
||
"line" | "area" | "radar" => Icon::ChartLine,
|
||
"pie" | "doughnut" => Icon::ChartPie,
|
||
"scatter" | "bubble" => Icon::ChartScatter,
|
||
_ => Icon::Chart,
|
||
}
|
||
}
|
||
|
||
fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||
ui.horizontal(|ui| {
|
||
for tab in ["Recommended Charts", "All Charts"] {
|
||
if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab {
|
||
d.tab = tab.to_string();
|
||
select_chart(d, 0);
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let choices = chart_choices(d);
|
||
let t = crate::theme::Tokens::get(ui.ctx());
|
||
ui.horizontal_top(|ui| {
|
||
ui.vertical(|ui| {
|
||
ui.set_width(190.0);
|
||
egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| {
|
||
for (i, (kind, _, label)) in choices.iter().enumerate() {
|
||
let resp = ui
|
||
.horizontal(|ui| {
|
||
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||
icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text);
|
||
ui.selectable_label(i == d.list_index, label.as_str())
|
||
})
|
||
.inner;
|
||
if resp.clicked() {
|
||
select_chart(d, i);
|
||
}
|
||
if resp.double_clicked() {
|
||
select_chart(d, i);
|
||
*confirm = true;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(12.0);
|
||
ui.vertical(|ui| {
|
||
let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default();
|
||
ui.strong(label);
|
||
let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover());
|
||
ui.painter().rect_filled(rect, 2.0, Color32::WHITE);
|
||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||
let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
|
||
if let Some(doc) = app.session.active()
|
||
&& let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms)
|
||
{
|
||
crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart);
|
||
}
|
||
});
|
||
});
|
||
ok_cancel(ui, confirm, open);
|
||
if *confirm {
|
||
*confirm = false;
|
||
let params =
|
||
json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
|
||
match app.run("insert.chart", params) {
|
||
Ok(r) => {
|
||
// The new chart is selected, as after inserting any object.
|
||
app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok());
|
||
*open = false;
|
||
}
|
||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
|
||
ui.label(label);
|
||
let mut v = text(d, key);
|
||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
|
||
d.values.insert(key.into(), json!(v));
|
||
}
|
||
};
|
||
edit(ui, d, "range", "Chart data range:");
|
||
ui.add_space(6.0);
|
||
ui.strong("Legend Entries (Series)");
|
||
let names: Vec<String> =
|
||
d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default();
|
||
let mut remove: Vec<u64> =
|
||
d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();
|
||
let kept = names.len().saturating_sub(remove.len());
|
||
egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| {
|
||
for (i, name) in names.iter().enumerate() {
|
||
let i = i as u64;
|
||
let removed = remove.contains(&i);
|
||
ui.horizontal(|ui| {
|
||
let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() };
|
||
ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) });
|
||
if removed {
|
||
if ui.small_button("Keep").clicked() {
|
||
remove.retain(|x| *x != i);
|
||
}
|
||
} else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() {
|
||
remove.push(i);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
d.values.insert("remove".into(), json!(remove));
|
||
ui.add_space(6.0);
|
||
edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");
|
||
ok_cancel(ui, confirm, open);
|
||
if *confirm {
|
||
*confirm = false;
|
||
let mut params = serde_json::Map::new();
|
||
params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null));
|
||
let range_changed = text(d, "range") != text(d, "range0");
|
||
if range_changed {
|
||
params.insert("range".into(), json!(text(d, "range")));
|
||
} else if !remove.is_empty() {
|
||
// A new range rebuilds the series; removals only apply to the current ones.
|
||
params.insert("removeSeries".into(), json!(remove));
|
||
}
|
||
if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {
|
||
params.insert("categories".into(), json!(text(d, "categories")));
|
||
}
|
||
if params.len() == 1 {
|
||
*open = false;
|
||
return;
|
||
}
|
||
match app.run("chart.selectData", Json::Object(params)) {
|
||
Ok(_) => *open = false,
|
||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||
let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null);
|
||
let rules = list["rules"].as_array().cloned().unwrap_or_default();
|
||
if rules.is_empty() {
|
||
ui.label("There are no conditional formatting rules on this sheet.");
|
||
}
|
||
for (i, r) in rules.iter().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default();
|
||
ui.label(format!(
|
||
"{}. {kind} applies to {}",
|
||
i + 1,
|
||
r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default()
|
||
));
|
||
if ui.small_button("▲").clicked() {
|
||
app.run_or_alert("home.manageRules", json!({"moveUp": i}));
|
||
}
|
||
if ui.small_button("▼").clicked() {
|
||
app.run_or_alert("home.manageRules", json!({"moveDown": i}));
|
||
}
|
||
if ui.small_button("Delete").clicked() {
|
||
app.run_or_alert("home.manageRules", json!({"delete": i}));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(480.0).hint_text("Type a command, e.g. freeze, chart, bold…"));
|
||
r.request_focus();
|
||
let q = d.search.to_ascii_lowercase();
|
||
let cmds: Vec<(String, String, String)> = app
|
||
.session
|
||
.commands()
|
||
.into_iter()
|
||
.filter(|c| c.enabled)
|
||
.filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q))
|
||
.map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › ")))
|
||
.take(40)
|
||
.collect();
|
||
if ui.input(|i| i.key_pressed(Key::ArrowDown)) {
|
||
d.list_index = (d.list_index + 1).min(cmds.len().saturating_sub(1));
|
||
}
|
||
if ui.input(|i| i.key_pressed(Key::ArrowUp)) {
|
||
d.list_index = d.list_index.saturating_sub(1);
|
||
}
|
||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||
for (i, (id, label, menu)) in cmds.iter().enumerate() {
|
||
let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(480.0, 20.0)));
|
||
if r.clicked() {
|
||
d.list_index = i;
|
||
*confirm = true;
|
||
}
|
||
r.on_hover_text(id);
|
||
}
|
||
});
|
||
if ui.input(|i| i.key_pressed(Key::Enter)) {
|
||
*confirm = true;
|
||
}
|
||
if *confirm && let Some((id, ..)) = cmds.get(d.list_index) {
|
||
let id = id.clone();
|
||
app.dialog = None;
|
||
app.run_or_alert(&id, json!({}));
|
||
}
|
||
}
|
||
|
||
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"));
|
||
egui::Grid::new("gs").show(ui, |ui| {
|
||
ui.label("Set cell:");
|
||
ui.text_edit_singleline(&mut set);
|
||
ui.end_row();
|
||
ui.label("To value:");
|
||
ui.text_edit_singleline(&mut to);
|
||
ui.end_row();
|
||
ui.label("By changing cell:");
|
||
ui.text_edit_singleline(&mut changing);
|
||
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);
|
||
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).
|
||
fn toolpak_fields(tool: &str) -> 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:" }];
|
||
match tool {
|
||
"descriptive" => vec![
|
||
range(),
|
||
grouped(),
|
||
labels(),
|
||
Check { key: "summary", label: "Summary statistics" },
|
||
Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" },
|
||
Text { key: "kthLargest", label: "Kth Largest (blank for none):" },
|
||
Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" },
|
||
],
|
||
"histogram" => vec![
|
||
range(),
|
||
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||
labels(),
|
||
Check { key: "pareto", label: "Pareto (sorted histogram)" },
|
||
Check { key: "cumulative", label: "Cumulative Percentage" },
|
||
],
|
||
"correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()],
|
||
"anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }],
|
||
"regression" => vec![
|
||
Text { key: "inputY", label: "Input Y Range:" },
|
||
Text { key: "inputX", label: "Input X Range:" },
|
||
labels(),
|
||
Check { key: "constantZero", label: "Constant is Zero" },
|
||
Text { key: "confidence", label: "Confidence Level (%):" },
|
||
Check { key: "residuals", label: "Residuals" },
|
||
Check { key: "standardizedResiduals", label: "Standardized Residuals" },
|
||
Check { key: "normalProbability", label: "Normal Probability" },
|
||
],
|
||
"movingAverage" => {
|
||
vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }]
|
||
}
|
||
"exponentialSmoothing" => {
|
||
vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }]
|
||
}
|
||
"fTest" => {
|
||
let mut v = two();
|
||
v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]);
|
||
v
|
||
}
|
||
_ => {
|
||
let mut v = two();
|
||
v.extend([
|
||
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
|
||
labels(),
|
||
Text { key: "alpha", label: "Alpha:" },
|
||
]);
|
||
v
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The starting values of a tool's form: the selection as its input, a new sheet as output.
|
||
fn toolpak_defaults(sel: &str, tool: &str) -> Map<String, Json> {
|
||
let mut v = json!({
|
||
"tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "",
|
||
"groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "",
|
||
"interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0",
|
||
"outputTo": "newSheet", "output": "", "newSheet": "",
|
||
});
|
||
if tool == "regression" {
|
||
v["confidence"] = json!("95");
|
||
v["inputY"] = json!(sel);
|
||
}
|
||
v.as_object().cloned().unwrap_or_default()
|
||
}
|
||
|
||
/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak`
|
||
/// (through the dialog's command); a report that would replace data asks first.
|
||
fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||
use gridcraft_engine::cmd::analysis::TOOLS;
|
||
if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) {
|
||
ui.label("The output range already holds data. Replace it with the report?");
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Replace").clicked() {
|
||
d.values.remove("askOverwrite");
|
||
d.values.insert("overwrite".into(), json!(true));
|
||
*confirm = true;
|
||
}
|
||
if ui.button("Cancel").clicked() {
|
||
d.values.remove("askOverwrite");
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string();
|
||
let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else {
|
||
ui.label(egui::RichText::new("Analysis Tools").strong());
|
||
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||
let mut chosen = false;
|
||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||
for (id, label) in TOOLS {
|
||
let r = ui.selectable_label(pick == *id, *label);
|
||
if r.clicked() {
|
||
d.values.insert("pick".into(), json!(id));
|
||
}
|
||
if r.double_clicked() {
|
||
chosen = true;
|
||
}
|
||
}
|
||
});
|
||
let mut ok = false;
|
||
ok_cancel(ui, &mut ok, open);
|
||
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
|
||
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
|
||
d.values = toolpak_defaults(&sel, &pick);
|
||
}
|
||
return;
|
||
};
|
||
d.title = label.to_string();
|
||
d.fields = toolpak_fields(&tool);
|
||
form_fields(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() {
|
||
d.values.insert("outputTo".into(), json!(choice));
|
||
}
|
||
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));
|
||
}
|
||
});
|
||
}
|
||
ok_cancel(ui, confirm, open);
|
||
}
|
||
|
||
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
|
||
/// command; the result opens Solver Results.
|
||
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
|
||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
|
||
let mut t = text(d, k);
|
||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
|
||
d.values.insert(k.into(), json!(t));
|
||
}
|
||
};
|
||
ui.horizontal(|ui| {
|
||
ui.label("Set Objective:");
|
||
edit(ui, d, "objective", 200.0);
|
||
});
|
||
let goal = text(d, "goal");
|
||
ui.horizontal(|ui| {
|
||
ui.label("To:");
|
||
for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] {
|
||
if ui.radio(goal == g, label).clicked() {
|
||
d.values.insert("goal".into(), json!(g));
|
||
}
|
||
}
|
||
edit(ui, d, "value", 80.0);
|
||
});
|
||
ui.label("By Changing Variable Cells:");
|
||
edit(ui, d, "variables", 420.0);
|
||
ui.label("Subject to the Constraints:");
|
||
let list: Vec<String> = d
|
||
.values
|
||
.get("constraints")
|
||
.and_then(Json::as_array)
|
||
.map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect())
|
||
.unwrap_or_default();
|
||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||
ui.set_min_size(vec2(420.0, 90.0));
|
||
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
|
||
for (i, c) in list.iter().enumerate() {
|
||
if ui.selectable_label(d.list_index == i, c).clicked() {
|
||
d.list_index = i;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
edit(ui, d, "newLhs", 110.0);
|
||
let op = text(d, "newOp");
|
||
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
|
||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||
if ui.selectable_label(op == o, o).clicked() {
|
||
d.values.insert("newOp".into(), json!(o));
|
||
}
|
||
}
|
||
});
|
||
if !matches!(op.as_str(), "int" | "bin") {
|
||
edit(ui, d, "newRhs", 110.0);
|
||
}
|
||
if ui.button("Add").clicked() {
|
||
let lhs = text(d, "newLhs");
|
||
if !lhs.trim().is_empty() {
|
||
let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) };
|
||
let mut list = list.clone();
|
||
list.push(format!("{} {op}{rhs}", lhs.trim()));
|
||
d.values.insert("constraints".into(), json!(list));
|
||
d.values.insert("newLhs".into(), json!(""));
|
||
d.values.insert("newRhs".into(), json!(""));
|
||
}
|
||
}
|
||
if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() {
|
||
let mut list = list.clone();
|
||
if d.list_index < list.len() {
|
||
list.remove(d.list_index);
|
||
}
|
||
d.values.insert("constraints".into(), json!(list));
|
||
}
|
||
});
|
||
let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true);
|
||
if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() {
|
||
d.values.insert("nonNegative".into(), json!(nonneg));
|
||
}
|
||
let method = text(d, "method");
|
||
ui.horizontal(|ui| {
|
||
ui.label("Select a Solving Method:");
|
||
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
|
||
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
|
||
for m in ["grg", "simplex"] {
|
||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||
d.values.insert("method".into(), json!(m));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.label(
|
||
egui::RichText::new(if method == "simplex" {
|
||
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
|
||
} else {
|
||
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
|
||
})
|
||
.small(),
|
||
);
|
||
ui.add_space(8.0);
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let solve =
|
||
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||
if solve.clicked() && !text(d, "objective").trim().is_empty() {
|
||
*confirm = true;
|
||
}
|
||
if ui.button(" Close ").clicked() {
|
||
*open = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Solver Results: the outcome, and keeping or restoring the variable cells.
|
||
fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||
let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string();
|
||
ui.label(message);
|
||
let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false);
|
||
let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false);
|
||
if changed {
|
||
ui.add_space(4.0);
|
||
if ui.radio(!restore, "Keep Solver Solution").clicked() {
|
||
d.values.insert("restore".into(), json!(false));
|
||
}
|
||
if ui.radio(restore, "Restore Original Values").clicked() {
|
||
d.values.insert("restore".into(), json!(true));
|
||
}
|
||
}
|
||
let mut confirm = false;
|
||
ok_cancel(ui, &mut confirm, open);
|
||
if confirm {
|
||
// The solution (and the saved model) is one undo step.
|
||
if changed && restore {
|
||
app.run_or_alert("edit.undo", json!({}));
|
||
}
|
||
*open = false;
|
||
}
|
||
}
|
||
|
||
fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||
let r = d.result.clone().unwrap_or(Json::Null);
|
||
if let Some(e) = r.get("error").and_then(Json::as_str) {
|
||
ui.label(crate::clean_error(e));
|
||
return;
|
||
}
|
||
let issues = r["issues"].as_array().cloned().unwrap_or_default();
|
||
let Some(it) = issues.get(d.list_index) else {
|
||
ui.label("The spelling check is complete for the entire sheet.");
|
||
if ui.button("OK").clicked() {
|
||
*open = false;
|
||
}
|
||
return;
|
||
};
|
||
let word = it["word"].as_str().unwrap_or("").to_string();
|
||
let cell = it["cell"].as_str().unwrap_or("").to_string();
|
||
ui.label(format!("Not in Dictionary ({cell}):"));
|
||
ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C)));
|
||
let sugg: Vec<String> =
|
||
it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
ui.label("Suggestions:");
|
||
let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||
for s in &sugg {
|
||
if ui.selectable_label(pick == *s, s).clicked() {
|
||
pick = s.clone();
|
||
}
|
||
}
|
||
if sugg.is_empty() {
|
||
ui.label(egui::RichText::new("(No suggestions)").italics());
|
||
}
|
||
d.values.insert("pick".into(), json!(pick.clone()));
|
||
let _ = app.session.run("selection.set", json!({"cell": cell}));
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Ignore Once").clicked() {
|
||
d.list_index += 1;
|
||
}
|
||
if ui.button("Add to Dictionary").clicked() {
|
||
let _ = app.session.run("review.addToDictionary", json!({"word": word}));
|
||
d.list_index += 1;
|
||
}
|
||
if !pick.is_empty() {
|
||
if ui.button("Change").clicked() {
|
||
app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick}));
|
||
d.list_index += 1;
|
||
}
|
||
if ui.button("Change All").clicked() {
|
||
app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true}));
|
||
d.list_index += 1;
|
||
}
|
||
}
|
||
});
|
||
}
|