Files
gridcraft/crates/ui-egui/src/dialogs.rs
T
Brandon Thomas 2af0984af0 Merge pull request #308 from vangel76/feat/review-allow-edit-ranges
Review: Allow Edit Ranges with Excel-compatible password hashes (#307)

# Conflicts:
#	ROADMAP.md
#	crates/xlsx/src/sheet_write.rs
#	crates/xlsx/src/tests/malformed.rs
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-11 12:05:30 -04:00

1790 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, vec2};
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"}),
),
"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}),
)
}
"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": ""})),
"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": ""})),
"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
}
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
"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 = Some("comments".into());
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" => 520.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),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"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}"));
}
});
}
"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(_) => open = false,
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) {
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();
}
});
ok_cancel(ui, confirm, open);
}
/// 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");
}
}
}
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::ribbon::FONTS {
ui.selectable_value(&mut st.font.name, f.to_string(), *f);
}
});
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;
}
});
}
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;
}
}
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;
}
}
});
}