diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8d8e71665..3a6003f0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,32 @@ use serde_json::{Map, Value as Json, json}; use crate::SheetApp; use crate::theme; +mod about; +mod analysis; +mod charts; +mod command_search; +mod cond_format_rules; +mod data; +mod find; +mod format_cells; +mod insert_function; +mod macros; +mod names; +mod paste; +mod show; +mod spelling; +mod zoom; + +use analysis::toolpak_defaults; +use charts::select_chart; +use data::{abs, data_validation_fields, remove_duplicates_range, validation_defaults}; +use find::go_to_entries; +pub(crate) use names::insert_name; +use names::pasteable_names; +pub(crate) use show::build_params; +pub use show::show; +use zoom::zoom_preset; + #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, @@ -999,2400 +1025,6 @@ impl Dialog { } } -/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without -/// edits keeps it; a plain list rule when the cell has none. -fn validation_defaults(app: &SheetApp) -> Json { - let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); - let Some(doc) = app.session.active() else { return v }; - let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { - return v; - }; - // The enums serialize as their variant names; the command takes them in camelCase. - let camel = |x: Json| { - let name = x.as_str().unwrap_or_default(); - let mut chars = name.chars(); - chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() - }; - v["type"] = json!(camel(json!(dv.kind))); - v["operator"] = json!(camel(json!(dv.op))); - v["errorStyle"] = json!(camel(json!(dv.error_style))); - v["formula1"] = json!(dv.f1); - if let Some(f2) = &dv.f2 { - v["formula2"] = json!(f2); - } - v["ignoreBlank"] = json!(dv.allow_blank); - v["dropdown"] = json!(dv.in_cell_dropdown); - v["inputTitle"] = json!(dv.input_title); - v["inputMessage"] = json!(dv.input_message); - v["errorTitle"] = json!(dv.error_title); - v["errorMessage"] = json!(dv.error_message); - v -} - -/// The range Remove Duplicates starts with. A block selection is used as given; with only the -/// cursor placed, the table's data rows or the current region, as the command picks when it gets -/// no range (a single-cell range would remove nothing). -fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { - let Some(d) = app.session.active() else { return sel.to_string() }; - let Some(sh) = d.wb.active() else { return sel.to_string() }; - if !d.selection.current().is_single() { - return sel.to_string(); - } - let range = match sh.table_at(d.selection.active) { - Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), - None => gridcraft_engine::selection::current_region(sh, d.selection.active), - }; - range.a1() -} - -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)) } -} - -/// The criterion controls in Excel's Settings tab depend on both Allow and Data. -/// `formula1` is the sole source for a list or custom rule; `formula2` is a -/// second bound only for Between and Not between. -fn data_validation_fields(kind: &str, operator: &str) -> Vec { - use Field::*; - let mut fields = 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"), - ], - }]; - let two_bounds = matches!(operator, "between" | "notBetween"); - match kind { - "list" => { - fields.push(Text { key: "formula1", label: "Source:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); - } - "custom" => { - fields.push(Text { key: "formula1", label: "Formula:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - "whole" | "decimal" | "date" | "time" | "textLength" => { - fields.push(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"), - ], - }); - let lower = matches!(operator, "greater" | "greaterOrEqual"); - let (first, second) = match kind { - "date" => ( - if two_bounds || lower { - "Start date:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End date:" - } else { - "Date:" - }, - "End date:", - ), - "time" => ( - if two_bounds || lower { - "Start time:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End time:" - } else { - "Time:" - }, - "End time:", - ), - _ => ( - if two_bounds || lower { - "Minimum:" - } else if matches!(operator, "less" | "lessOrEqual") { - "Maximum:" - } else { - "Value:" - }, - "Maximum:", - ), - }; - fields.push(Text { key: "formula1", label: first }); - if two_bounds { - fields.push(Text { key: "formula2", label: second }); - } - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - _ => {} - } - fields.push(Text { key: "inputMessage", label: "Input message:" }); - fields.push(Choice { - key: "errorStyle", - label: "Error style", - options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], - }); - fields.push(Text { key: "errorMessage", label: "Error message:" }); - fields -} - -fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - d.fields = data_validation_fields(kind, operator); - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use Field::*; - let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); - d.fields = vec![ - Choice { - key: "action", - label: "Action", - options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], - }, - Text { key: "range", label: "List range:" }, - Text { key: "criteria", label: "Criteria range (optional):" }, - ]; - if copy { - d.fields.push(Text { key: "copyTo", label: "Copy to:" }); - } - d.fields.push(Check { key: "unique", label: "Unique rows only" }); - form_fields(ui, d); - // Constrain the button row: the shared right-to-left layout otherwise - // consumes the window's remaining height instead of fitting the buttons. - ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); -} - -/// Builds the command params from form values for dialogs that need translation. -/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. -pub(crate) fn build_params(d: &Dialog) -> Json { - let mut v = Json::Object(d.values.clone()); - match d.name.as_str() { - "advancedFilter" => { - if let Some(m) = v.as_object_mut() { - m.remove("action"); - if d.values.get("action").and_then(Json::as_str) != Some("copy") { - m.remove("copyTo"); - } - if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { - m.remove("criteria"); - } - } - } - "dataValidation" => { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - if let Some(m) = v.as_object_mut() { - if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { - m.remove("formula2"); - } - if matches!(kind, "any" | "list" | "custom") { - m.remove("operator"); - } - if kind == "any" { - m.remove("formula1"); - } - if kind != "list" { - m.remove("dropdown"); - } - } - } - "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!("")); - } - } - } - "cfQuick" => { - let mut rule = Map::new(); - for (k, val) in &d.values { - rule.insert(k.clone(), val.clone()); - } - v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { - v["sheet"] = json!(pick.sheet.as_str()); - v["range"] = json!(pick.applies_to); - } - } - "series" => { - if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().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 -} - -/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. -pub fn show(app: &mut SheetApp, ctx: &egui::Context) { - let Some(mut d) = app.dialog.take() else { return }; - let home = app.ui_window(); - 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, - "zoom" => 220.0, - "pasteSpecial" => 480.0, - _ => 380.0, - }; - let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::unique("dialog").with(&d.name)) - .collapsible(false) - .resizable(false) - .default_width(width) - // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) - .open(&mut win_open); - let window = match d.name.as_str() { - "insertPicture" => window.default_height(240.0), - "advancedFilter" => window.default_height(0.0), - _ => 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), - "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), - "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), - "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), - "find" => find(app, ui, &mut d), - "goTo" => go_to(app, ui, &mut d, &mut confirm), - "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), - "sort" => sort(app, ui, &mut d, &mut confirm), - "nameManager" => name_manager(app, ui, &mut d, &mut open), - "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &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), - "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), - "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(app, 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::>()))) - } 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(std::ffi::OsStr::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 = 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 { - // A bare name has no folder: written as typed it would land in the - // process's working directory (`/` from the Finder, the install folder on - // Windows), never where the user looks. The picker chooses the folder. - let pick = app.services.pick_save.as_ref(); - let target = if path.is_empty() { - pick.and_then(|f| f(&format!("Copy.{fmt}"))) - } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { - f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{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"))); - let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); - ui.horizontal(|ui| { - for (label, answer) in - [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] - { - if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { - 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 = 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); - } else if d.name == "openPassword" { - let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); - app.after_open(path.as_deref(), &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)); - } - // A wrong password: say so, clear the field and stay open for another try. - Err(e) if d.name == "openPassword" => { - d.values.insert("password".into(), json!("")); - app.message = Some((d.title.clone(), crate::clean_error(&e))); - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } else if confirm { - open = false; - } - if app.ui_window() != home { - // A command run from the dialog switched windows: whatever it opened shows in the new - // window, and this dialog (or the one it returns to) stays in its own. - let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; - if let Some(keep) = keep { - app.return_dialog(home, keep); - } - return; - } - 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 as Excel lays it out: Paste and Operation each in two columns of options, Skip -/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options -/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. -fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = app.ui.language; - let t = theme::Tokens::get(ui.ctx()); - let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d - .fields - .iter() - .filter_map(|f| match f { - Field::Choice { key, label, options } => Some((*key, *label, options.clone())), - _ => None, - }) - .collect(); - for (key, label, options) in choices { - ui.add_space(4.0); - ui.label(egui::RichText::new(lang.tr(label)).strong()); - egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { - ui.set_min_width(ui.available_width()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - // Column by column, as Excel fills them. - let rows = options.len().div_ceil(2); - egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for r in 0..rows { - for c in 0..2 { - if let Some((value, text)) = options.get(c * rows + r) { - if ui.radio(cur == *value, lang.tr(text)).clicked() { - d.set_value(key, json!(value)); - } - } else { - ui.label(""); - } - } - ui.end_row(); - } - }); - }); - } - ui.add_space(8.0); - // In the same columns as the options above. - egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { - egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - ui.end_row(); - }); - }); - ui.add_space(8.0); - ui.separator(); - ui.horizontal(|ui| { - // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { - d.values.insert("what".into(), json!("all")); - d.values.insert("operation".into(), json!("none")); - d.values.insert("link".into(), json!(true)); - *confirm = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }); - }); -} - -/// 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); - // A row as tall as the buttons: a right-to-left layout straight in the window takes all the - // height it is offered, which stretched every form dialog to the screen's height. - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let lang = crate::i18n::current(ui.ctx()); - let t = crate::theme::Tokens::get(ui.ctx()); - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("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); -} - -/// Gives keyboard focus to the first text or number field the first time a form is drawn. A -/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and -/// Enter sets the width, as in Excel. -/// Keeps asking until the field really has focus: a new window's first frame is an invisible -/// sizing pass where a focus request is ignored. -fn take_first_focus(d: &mut Dialog, r: &egui::Response) { - if !d.focus_first_field { - return; - } - if !r.has_focus() { - r.request_focus(); - return; - } - // Focus has landed, and the field is now a text box: select its whole value so typing - // replaces it (the field's own select-on-focus fired before it was a text box). - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); - state.cursor.set_char_range(Some(all)); - state.store(&r.ctx, r.id); - d.focus_first_field = false; -} - -/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a -/// reference in it (the click took the focus), its caret after the reference. -fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { - if typed { - pick.point = None; - } - if pick.refocus { - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); - state.store(&r.ctx, r.id); - r.request_focus(); - pick.refocus = false; - } else if r.has_focus() - && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) - { - pick.caret = range.primary.index.into(); - } -} - -/// Draws a dialog's `fields`, editing its values. -fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { - // Field labels, choices and notes are English source strings, translated as they are drawn. - let lang = crate::i18n::current(ui.ctx()); - egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - for f in d.fields.clone() { - match f { - Field::Text { key, label } => { - ui.vertical(|ui| { - let label = ui.label(lang.tr(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)).labelled_by(label.id) - } else { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) - }; - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(s)); - } - if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { - track_pick(pick, &r, r.changed()); - } - }); - } - Field::Password { key, label } => { - // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. - ui.vertical(|ui| { - let label = ui.label(lang.tr(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)).labelled_by(label.id).changed() - { - d.values.insert(key.into(), json!(s)); - } - ui.checkbox(&mut d.show_password, lang.tr("Show password")); - }); - } - Field::Number { key, label } => { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(label)); - let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); - // Typed values count straight away: otherwise Enter or OK in the same frame - // confirms the old value, because a number field only commits when it loses focus. - let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(n)); - } - }); - } - Field::Choice { key, label, options } => { - ui.vertical(|ui| { - let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if d.name == "dataValidation" { - let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); - egui::ComboBox::from_id_salt(("dataValidation", key)) - .selected_text(selected) - .width(300.0) - .show_ui(ui, |ui| { - for (val, text) in options { - if ui.selectable_label(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - }) - .response - .labelled_by(field_label.id); - } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, 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, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - Field::Note(t) => { - // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a - // context separator, so such a note is shown as it is. - let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; - ui.label(egui::RichText::new(text).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(app: &mut SheetApp, 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 = 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; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - 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" })); - } - }); - } - }); -} - -/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. -fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn - // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a - // different word in some languages. - let lang = app.ui.language; - ui.horizontal(|ui| { - for (tab, key) in [ - ("Number", "Number"), - ("Alignment", "Alignment"), - ("Font", "Font"), - ("Border", "Border"), - ("Fill", "Format Cells|Fill"), - ("Protection", "Protection"), - ] { - if ui.selectable_label(d.tab == tab, lang.tr(key)).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(lang.tr("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, lang.tr(name)).clicked() { - code = c; - } - } - cols[1].label(egui::RichText::new(lang.tr("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); - let label = cols[1].label(lang.tr("Type (custom format code):")); - cols[1].text_edit_singleline(&mut code).labelled_by(label.id); - 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(lang.tr("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, lang.tr(n)); - } - }); - ui.label(egui::RichText::new(lang.tr("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, lang.tr(n)); - } - }); - let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("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(lang.tr("Orientation (degrees)"))); - st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); - ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); - } - "Font" => { - egui::ComboBox::from_label(lang.tr("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(lang.tr("Size"))); - ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, lang.tr("Bold")); - ui.checkbox(&mut st.font.italic, lang.tr("Italic")); - ui.checkbox(&mut st.font.strike, lang.tr("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, lang.tr(n)); - } - }); - ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); - }); - ui.label(lang.tr("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, lang.tr("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(lang.tr("None")).clicked() { - st.borders = Default::default(); - } - if ui.button(lang.tr("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), - ] { - let n = lang.tr(n); - 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 label = lang.tr(label); - let response = ui.selectable_label(slot.style == s, label); - let selected = slot.style == s; - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, 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(lang.tr("Background Color:")); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("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, lang.tr("Locked")); - ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); - ui.label( - egui::RichText::new( - lang.tr("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 - } -} - -/// Insert Function: searches names (as the formula language spells them) and descriptions, shows -/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). -fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let language = app.ui.language; - ui.horizontal(|ui| { - ui.label(language.tr("Search:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); - r.request_focus(); - }); - let locale = language.formula_locale(); - let list = gridcraft_engine::cmd::formulas::function_list(); - // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_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_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_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 category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).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) { - let name = f["name"].as_str().unwrap_or(""); - ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(name, locale, language).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; - } -} - -/// Excel's name for a function category, given the catalog's id (`MathTrig`). -fn function_category_label(id: &str) -> &str { - match id { - "DateTime" => "Date & Time", - "Lookup" => "Lookup & Reference", - "MathTrig" => "Math & Trig", - "AddIn" => "Add-in and Automation", - // Financial, Logical, Text, Statistical, Engineering, Database, Information, … - other => other, - } -} - -/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. -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| { - let label = ui.label("Find what:"); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); - // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. - if !d.values.contains_key("focused") { - r.request_focus(); - d.values.insert("focused".into(), json!(true)); - } - }); - if d.tab == "Replace" { - ui.horizontal(|ui| { - let label = ui.label("Replace with:"); - ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); - }); - } - 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; - } - } - }); - } -} - -/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. -const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; - -/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. -fn zoom_preset(percent: f64) -> &'static str { - ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") -} - -/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and -/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage -/// selects Custom; Fit selection zooms so the selected cells fill the grid. -fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); - ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); - for (val, text) in ZOOM_PRESETS { - if ui.radio(cur == val, text).clicked() { - d.set_value("preset", json!(val)); - } - } - if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { - d.set_value("preset", json!("fit")); - } - ui.horizontal(|ui| { - if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { - d.set_value("preset", json!("custom")); - } - // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. - let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; - if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { - d.set_value("percent", json!(percent)); - } - ui.label("%"); - }); - ok_cancel(ui, confirm, open); -} - -/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to -/// before, newest first. -fn go_to_entries(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| { - let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); - v.extend(doc.go_to_recent.iter().cloned()); - v - }) - .unwrap_or_default() -} - -/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. -fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let entries: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { - for n in entries.get(rows).unwrap_or_default() { - let resp = ui.selectable_label(current == *n, n); - if resp.clicked() || resp.double_clicked() { - d.values.insert("reference".into(), json!(n)); - } - if resp.double_clicked() { - *confirm = true; - } - } - }); - let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label(lang.tr("Reference:")); - ui.text_edit_singleline(&mut r).labelled_by(label.id).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, "record": true})); - app.grid.ensure_visible = true; - } - if !open { - *confirm = true; - } -} - -/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. -fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - 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 = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); - // Columns of the region around the active cell, labelled with their header (or first value). - let columns: Vec<(String, String)> = app - .session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - let region = gridcraft_engine::selection::current_region(sh, d.selection.active); - Some( - (region.start.col..=region.end.col) - .map(|c| { - let letter = gridcraft_engine::core::col_to_letters(c); - // The region starts at the header row when one is present, so the - // label always comes from start.row: header text with headers on, - // first data value with them off. - let label_row = region.start.row; - let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); - let text: String = text.chars().take(24).collect(); - (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) - }) - .collect(), - ) - }) - .unwrap_or_default(); - let mut remove = None; - for (i, lv) in levels.iter_mut().enumerate() { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); - let mut col = lv["column"].as_str().unwrap_or("A").to_string(); - if columns.is_empty() { - ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); - } else { - egui::ComboBox::from_id_salt(("sort_col", i)) - .selected_text(col.clone()) - .show_ui(ui, |ui| { - for (letter, label) in &columns { - ui.selectable_value(&mut col, letter.clone(), label.as_str()); - } - }) - .response - .labelled_by(label.id); - } - 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"); - }) - .response - .accessible_name(lang.tr("Sort On")); - 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 crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove - && levels.len() > 1 - { - levels.remove(i); - } - if ui.button("+ Add Level").clicked() { - let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); - let next = columns - .iter() - .map(|(l, _)| l) - .find(|l| !used.contains(l)) - .cloned() - .or_else(|| columns.last().map(|(l, _)| l.clone())) - .unwrap_or_else(|| "B".into()); - levels.push(json!({"column": next, "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::()); - 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; - } - }); -} - -/// The names Paste Names offers in the active workbook. -fn pasteable_names(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) - .unwrap_or_default() -} - -/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it -/// starts one in the active cell with `=` and the name. -pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { - match app.editor.as_mut() { - Some(ed) => ed.insert_at_caret(name), - None => app.begin_edit(Some(format!("={name}")), false), - } -} - -/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the -/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it -/// refers to from the active cell. -fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let names: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - // The `name` value is the selection, so `ui.dialog.set` can choose one too. - if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { - d.list_index = i; - } - let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); - let last = names.len().saturating_sub(1); - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - d.list_index = d.list_index.min(last); - ui.label(app.ui.language.tr("Paste name")); - if names.is_empty() { - ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); - } - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - let step = row_height + ui.spacing().item_spacing.y; - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { - let top = ui.cursor().top() - rows.start as f32 * step; - for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { - let r = ui.selectable_label(i == d.list_index, n); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - *confirm = true; - } - } - // The arrow keys can move the selection onto a row that isn't laid out. - if down || up { - let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); - if !ui.clip_rect().contains_rect(rect) { - ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); - } - } - }); - ui.add_space(4.0); - // Paste List is disabled while a cell is being edited. - if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { - app.run_or_alert("formulas.pasteName", json!({"list": true})); - *open = false; - return; - } - if let Some(n) = names.get(d.list_index) { - d.values.insert("name".into(), json!(n)); - } - ok_cancel(ui, confirm, open); -} - -/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 21] = [ - ("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"), - ("area", "stacked100", "100% 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))), - } - } -} - -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. -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| { - let label = ui.label(label); - let mut v = text(d, key); - if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).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 = - 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 = - 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::>().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 (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { - ( - i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), - i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), - i.consume_key(egui::Modifiers::CTRL, Key::Home), - i.consume_key(egui::Modifiers::CTRL, Key::End), - i.consume_key(egui::Modifiers::NONE, Key::PageUp), - i.consume_key(egui::Modifiers::NONE, Key::PageDown), - ) - }); - let width = ui.available_width(); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); - - if !d.focus_initialized { - r.request_focus(); - d.focus_initialized = true; - } - - 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(); - - let last = cmds.len().saturating_sub(1); - - let row_h = ui.spacing().interact_size.y.max(20.0); - let row_step = row_h + ui.spacing().item_spacing.y; - - const LIST_HEIGHT: f32 = 320.0; - - let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); - - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - if pgdn { - d.list_index = (d.list_index + page_size).min(last); - } - if pgup { - d.list_index = d.list_index.saturating_sub(page_size); - } - if home { - d.list_index = 0; - } - if end { - d.list_index = last; - } - - d.list_index = d.list_index.min(last); - - egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { - let scroll_area_width = ui.available_width(); - 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(scroll_area_width, 20.0))); - - if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { - r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); - } - - 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::().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 { - 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 { - 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 lang = crate::i18n::current(ui.ctx()); - 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, label: &str| { - let mut t = text(d, k); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { - d.values.insert(k.into(), json!(t)); - } - }; - ui.horizontal(|ui| { - ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0, "Set Objective:"); - }); - 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, "Value Of:"); - }); - ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); - ui.label("Subject to the Constraints:"); - let list: Vec = 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, "Constraint Cell Reference"); - 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)); - } - } - }) - .response - .accessible_name(lang.tr("Constraint Operator")); - if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0, "Constraint Value"); - } - 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| match m { - "simplex" => "Simplex LP", - "evolutionary" => "Evolutionary", - _ => "GRG Nonlinear", - }; - egui::ComboBox::from_id_salt("solverMethod") - .selected_text(label(&method)) - .show_ui(ui, |ui| { - for m in ["grg", "simplex", "evolutionary"] { - if ui.selectable_label(method == m, label(m)).clicked() { - d.values.insert("method".into(), json!(m)); - } - } - }) - .response - .accessible_name(lang.tr("Select a Solving Method:")); - }); - ui.label( - egui::RichText::new(match method.as_str() { - "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", - "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", - _ => "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 t = crate::theme::Tokens::get(ui.ctx()); - let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.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)); - } - } - // Reports describe the solution whether it is kept or not. - let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); - if solved { - ui.add_space(4.0); - ui.label("Reports:"); - for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { - let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut on, label).changed() { - d.values.insert(key.into(), json!(on)); - } - } - } - 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!({})); - } - let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] - .into_iter() - .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) - .map(|(_, kind)| kind) - .collect(); - if solved && !reports.is_empty() { - let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); - let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); - app.run_or_alert("data.solverReport", params); - } - *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(app.ui.language.tr_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(crate::theme::Tokens::get(ui.ctx()).danger)); - let sugg: Vec = - 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; - } - } - }); -} - -#[cfg(test)] -mod data_validation_tests { - use super::*; - - #[test] - fn list_uses_one_source_and_does_not_submit_a_second_list() { - let fields = data_validation_fields("list", "between"); - assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); - assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - fields, - json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), - ); - let params = build_params(&dialog); - assert_eq!(params["formula1"], "ABC,DEF"); - assert!(params.get("formula2").is_none()); - assert!(params.get("operator").is_none()); - } - - #[test] - fn only_between_operators_show_and_submit_two_bounds() { - let two = data_validation_fields("whole", "between"); - assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); - let one = data_validation_fields("date", "lessOrEqual"); - assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); - assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - one, - json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), - ); - let params = build_params(&dialog); - assert_eq!(params["operator"], "lessOrEqual"); - assert_eq!(params["formula1"], "2026-10-12"); - assert!(params.get("formula2").is_none()); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 000000000..a337627d9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,26 @@ +use super::*; + +/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits +/// are compiled in (`crate::credits`, docs/contributors.md). +pub(super) 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"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs new file mode 100644 index 000000000..595be229c --- /dev/null +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -0,0 +1,344 @@ +use super::show::{form_fields, ok_cancel}; +use super::*; + +pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); + 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::().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 { + 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. +pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { + 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. +pub(super) 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. +pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + 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, label: &str| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0, "Set Objective:"); + }); + 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, "Value Of:"); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); + ui.label("Subject to the Constraints:"); + let list: Vec = 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, "Constraint Cell Reference"); + 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)); + } + } + }) + .response + .accessible_name(lang.tr("Constraint Operator")); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0, "Constraint Value"); + } + 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| match m { + "simplex" => "Simplex LP", + "evolutionary" => "Evolutionary", + _ => "GRG Nonlinear", + }; + egui::ComboBox::from_id_salt("solverMethod") + .selected_text(label(&method)) + .show_ui(ui, |ui| { + for m in ["grg", "simplex", "evolutionary"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }) + .response + .accessible_name(lang.tr("Select a Solving Method:")); + }); + ui.label( + egui::RichText::new(match method.as_str() { + "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", + "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", + _ => "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 t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.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. +pub(super) 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)); + } + } + // Reports describe the solution whether it is kept or not. + let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); + if solved { + ui.add_space(4.0); + ui.label("Reports:"); + for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, label).changed() { + d.values.insert(key.into(), json!(on)); + } + } + } + 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!({})); + } + let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] + .into_iter() + .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) + .map(|(_, kind)| kind) + .collect(); + if solved && !reports.is_empty() { + let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); + let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); + app.run_or_alert("data.solverReport", params); + } + *open = false; + } +} diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs new file mode 100644 index 000000000..bd7da3d66 --- /dev/null +++ b/crates/ui-egui/src/dialogs/charts.rs @@ -0,0 +1,187 @@ +use super::show::ok_cancel; +use super::*; + +/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. +const ALL_CHARTS: [(&str, &str, &str); 21] = [ + ("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"), + ("area", "stacked100", "100% 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. +pub(super) 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, + } +} + +pub(super) 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))), + } + } +} + +/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. +pub(super) 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| { + let label = ui.label(label); + let mut v = text(d, key); + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).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 = + 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 = + 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))), + } + } +} diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs new file mode 100644 index 000000000..509ca4700 --- /dev/null +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -0,0 +1,88 @@ +use super::*; + +pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), + i.consume_key(egui::Modifiers::CTRL, Key::Home), + i.consume_key(egui::Modifiers::CTRL, Key::End), + i.consume_key(egui::Modifiers::NONE, Key::PageUp), + i.consume_key(egui::Modifiers::NONE, Key::PageDown), + ) + }); + let width = ui.available_width(); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); + + if !d.focus_initialized { + r.request_focus(); + d.focus_initialized = true; + } + + 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(); + + let last = cmds.len().saturating_sub(1); + + let row_h = ui.spacing().interact_size.y.max(20.0); + let row_step = row_h + ui.spacing().item_spacing.y; + + const LIST_HEIGHT: f32 = 320.0; + + let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); + + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + if pgdn { + d.list_index = (d.list_index + page_size).min(last); + } + if pgup { + d.list_index = d.list_index.saturating_sub(page_size); + } + if home { + d.list_index = 0; + } + if end { + d.list_index = last; + } + + d.list_index = d.list_index.min(last); + + egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { + let scroll_area_width = ui.available_width(); + 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(scroll_area_width, 20.0))); + + if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); + } + + 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!({})); + } +} diff --git a/crates/ui-egui/src/dialogs/cond_format_rules.rs b/crates/ui-egui/src/dialogs/cond_format_rules.rs new file mode 100644 index 000000000..216f79e31 --- /dev/null +++ b/crates/ui-egui/src/dialogs/cond_format_rules.rs @@ -0,0 +1,28 @@ +use super::*; + +pub(super) 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::>().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})); + } + }); + } +} diff --git a/crates/ui-egui/src/dialogs/data.rs b/crates/ui-egui/src/dialogs/data.rs new file mode 100644 index 000000000..615c64ab9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/data.rs @@ -0,0 +1,327 @@ +#[cfg(test)] +use super::show::build_params; +use super::show::{form_fields, ok_cancel}; +use super::*; + +/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without +/// edits keeps it; a plain list rule when the cell has none. +pub(super) fn validation_defaults(app: &SheetApp) -> Json { + let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); + let Some(doc) = app.session.active() else { return v }; + let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { + return v; + }; + // The enums serialize as their variant names; the command takes them in camelCase. + let camel = |x: Json| { + let name = x.as_str().unwrap_or_default(); + let mut chars = name.chars(); + chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() + }; + v["type"] = json!(camel(json!(dv.kind))); + v["operator"] = json!(camel(json!(dv.op))); + v["errorStyle"] = json!(camel(json!(dv.error_style))); + v["formula1"] = json!(dv.f1); + if let Some(f2) = &dv.f2 { + v["formula2"] = json!(f2); + } + v["ignoreBlank"] = json!(dv.allow_blank); + v["dropdown"] = json!(dv.in_cell_dropdown); + v["inputTitle"] = json!(dv.input_title); + v["inputMessage"] = json!(dv.input_message); + v["errorTitle"] = json!(dv.error_title); + v["errorMessage"] = json!(dv.error_message); + v +} + +/// The range Remove Duplicates starts with. A block selection is used as given; with only the +/// cursor placed, the table's data rows or the current region, as the command picks when it gets +/// no range (a single-cell range would remove nothing). +pub(super) fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { + let Some(d) = app.session.active() else { return sel.to_string() }; + let Some(sh) = d.wb.active() else { return sel.to_string() }; + if !d.selection.current().is_single() { + return sel.to_string(); + } + let range = match sh.table_at(d.selection.active) { + Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), + None => gridcraft_engine::selection::current_region(sh, d.selection.active), + }; + range.a1() +} + +pub(super) 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)) } +} + +/// The criterion controls in Excel's Settings tab depend on both Allow and Data. +/// `formula1` is the sole source for a list or custom rule; `formula2` is a +/// second bound only for Between and Not between. +pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec { + use Field::*; + let mut fields = 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"), + ], + }]; + let two_bounds = matches!(operator, "between" | "notBetween"); + match kind { + "list" => { + fields.push(Text { key: "formula1", label: "Source:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); + } + "custom" => { + fields.push(Text { key: "formula1", label: "Formula:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + fields.push(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"), + ], + }); + let lower = matches!(operator, "greater" | "greaterOrEqual"); + let (first, second) = match kind { + "date" => ( + if two_bounds || lower { + "Start date:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End date:" + } else { + "Date:" + }, + "End date:", + ), + "time" => ( + if two_bounds || lower { + "Start time:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End time:" + } else { + "Time:" + }, + "End time:", + ), + _ => ( + if two_bounds || lower { + "Minimum:" + } else if matches!(operator, "less" | "lessOrEqual") { + "Maximum:" + } else { + "Value:" + }, + "Maximum:", + ), + }; + fields.push(Text { key: "formula1", label: first }); + if two_bounds { + fields.push(Text { key: "formula2", label: second }); + } + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + _ => {} + } + fields.push(Text { key: "inputMessage", label: "Input message:" }); + fields.push(Choice { + key: "errorStyle", + label: "Error style", + options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], + }); + fields.push(Text { key: "errorMessage", label: "Error message:" }); + fields +} + +pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + d.fields = data_validation_fields(kind, operator); + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use Field::*; + let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); + d.fields = vec![ + Choice { + key: "action", + label: "Action", + options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], + }, + Text { key: "range", label: "List range:" }, + Text { key: "criteria", label: "Criteria range (optional):" }, + ]; + if copy { + d.fields.push(Text { key: "copyTo", label: "Copy to:" }); + } + d.fields.push(Check { key: "unique", label: "Unique rows only" }); + form_fields(ui, d); + // Constrain the button row: the shared right-to-left layout otherwise + // consumes the window's remaining height instead of fitting the buttons. + ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); +} + +/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. +pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + 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 = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); + // Columns of the region around the active cell, labelled with their header (or first value). + let columns: Vec<(String, String)> = app + .session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + let region = gridcraft_engine::selection::current_region(sh, d.selection.active); + Some( + (region.start.col..=region.end.col) + .map(|c| { + let letter = gridcraft_engine::core::col_to_letters(c); + // The region starts at the header row when one is present, so the + // label always comes from start.row: header text with headers on, + // first data value with them off. + let label_row = region.start.row; + let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); + let text: String = text.chars().take(24).collect(); + (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) + }) + .collect(), + ) + }) + .unwrap_or_default(); + let mut remove = None; + for (i, lv) in levels.iter_mut().enumerate() { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); + let mut col = lv["column"].as_str().unwrap_or("A").to_string(); + if columns.is_empty() { + ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); + } else { + egui::ComboBox::from_id_salt(("sort_col", i)) + .selected_text(col.clone()) + .show_ui(ui, |ui| { + for (letter, label) in &columns { + ui.selectable_value(&mut col, letter.clone(), label.as_str()); + } + }) + .response + .labelled_by(label.id); + } + 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"); + }) + .response + .accessible_name(lang.tr("Sort On")); + 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 crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove + && levels.len() > 1 + { + levels.remove(i); + } + if ui.button("+ Add Level").clicked() { + let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); + let next = columns + .iter() + .map(|(l, _)| l) + .find(|l| !used.contains(l)) + .cloned() + .or_else(|| columns.last().map(|(l, _)| l.clone())) + .unwrap_or_else(|| "B".into()); + levels.push(json!({"column": next, "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; + } +} + +#[cfg(test)] +mod data_validation_tests { + use super::*; + + #[test] + fn list_uses_one_source_and_does_not_submit_a_second_list() { + let fields = data_validation_fields("list", "between"); + assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); + assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + fields, + json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), + ); + let params = build_params(&dialog); + assert_eq!(params["formula1"], "ABC,DEF"); + assert!(params.get("formula2").is_none()); + assert!(params.get("operator").is_none()); + } + + #[test] + fn only_between_operators_show_and_submit_two_bounds() { + let two = data_validation_fields("whole", "between"); + assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); + let one = data_validation_fields("date", "lessOrEqual"); + assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); + assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + one, + json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), + ); + let params = build_params(&dialog); + assert_eq!(params["operator"], "lessOrEqual"); + assert_eq!(params["formula1"], "2026-10-12"); + assert!(params.get("formula2").is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs new file mode 100644 index 000000000..27bd3687e --- /dev/null +++ b/crates/ui-egui/src/dialogs/find.rs @@ -0,0 +1,151 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. +pub(super) 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| { + let label = ui.label("Find what:"); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); + // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. + if !d.values.contains_key("focused") { + r.request_focus(); + d.values.insert("focused".into(), json!(true)); + } + }); + if d.tab == "Replace" { + ui.horizontal(|ui| { + let label = ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); + }); + } + 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; + } + } + }); + } +} + +/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to +/// before, newest first. +pub(super) fn go_to_entries(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| { + let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); + v.extend(doc.go_to_recent.iter().cloned()); + v + }) + .unwrap_or_default() +} + +/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. +pub(super) fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let entries: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { + for n in entries.get(rows).unwrap_or_default() { + let resp = ui.selectable_label(current == *n, n); + if resp.clicked() || resp.double_clicked() { + d.values.insert("reference".into(), json!(n)); + } + if resp.double_clicked() { + *confirm = true; + } + } + }); + let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label(lang.tr("Reference:")); + ui.text_edit_singleline(&mut r).labelled_by(label.id).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, "record": true})); + app.grid.ensure_visible = true; + } + if !open { + *confirm = true; + } +} diff --git a/crates/ui-egui/src/dialogs/format_cells.rs b/crates/ui-egui/src/dialogs/format_cells.rs new file mode 100644 index 000000000..f28c1de1b --- /dev/null +++ b/crates/ui-egui/src/dialogs/format_cells.rs @@ -0,0 +1,227 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. +pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; + ui.horizontal(|ui| { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).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(lang.tr("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, lang.tr(name)).clicked() { + code = c; + } + } + cols[1].label(egui::RichText::new(lang.tr("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); + let label = cols[1].label(lang.tr("Type (custom format code):")); + cols[1].text_edit_singleline(&mut code).labelled_by(label.id); + 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(lang.tr("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, lang.tr(n)); + } + }); + ui.label(egui::RichText::new(lang.tr("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, lang.tr(n)); + } + }); + let mut indent = st.align.indent as f32; + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("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(lang.tr("Orientation (degrees)"))); + st.align.rotation = rot as i16; + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); + } + "Font" => { + egui::ComboBox::from_label(lang.tr("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(lang.tr("Size"))); + ui.horizontal(|ui| { + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("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, lang.tr(n)); + } + }); + ui.horizontal(|ui| { + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); + }); + ui.label(lang.tr("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, lang.tr("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(lang.tr("None")).clicked() { + st.borders = Default::default(); + } + if ui.button(lang.tr("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), + ] { + let n = lang.tr(n); + 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 label = lang.tr(label); + let response = ui.selectable_label(slot.style == s, label); + let selected = slot.style == s; + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, 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(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("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, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); + ui.label( + egui::RichText::new( + lang.tr("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 + } +} diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs new file mode 100644 index 000000000..2a240b1af --- /dev/null +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -0,0 +1,72 @@ +use super::show::ok_cancel; +use super::*; + +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). +pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; + ui.horizontal(|ui| { + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); + r.request_focus(); + }); + let locale = language.formula_locale(); + let list = gridcraft_engine::cmd::formulas::function_list(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_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_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_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 category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).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) { + let name = f["name"].as_str().unwrap_or(""); + ui.separator(); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).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; + } +} + +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} diff --git a/crates/ui-egui/src/dialogs/macros.rs b/crates/ui-egui/src/dialogs/macros.rs new file mode 100644 index 000000000..269643f8d --- /dev/null +++ b/crates/ui-egui/src/dialogs/macros.rs @@ -0,0 +1,40 @@ +use super::*; + +/// 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. +pub(super) fn macros(app: &mut SheetApp, 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 = 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; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + 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" })); + } + }); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/names.rs b/crates/ui-egui/src/dialogs/names.rs new file mode 100644 index 000000000..f12374dae --- /dev/null +++ b/crates/ui-egui/src/dialogs/names.rs @@ -0,0 +1,134 @@ +use super::show::ok_cancel; +use super::*; + +pub(super) 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::()); + 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; + } + }); +} + +/// The names Paste Names offers in the active workbook. +pub(super) fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +pub(super) fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + let step = row_height + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { + let top = ui.cursor().top() - rows.start as f32 * step; + for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { + let r = ui.selectable_label(i == d.list_index, n); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + // The arrow keys can move the selection onto a row that isn't laid out. + if down || up { + let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); + if !ui.clip_rect().contains_rect(rect) { + ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs new file mode 100644 index 000000000..bc01a7da2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -0,0 +1,139 @@ +use super::*; + +/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip +/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options +/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. +pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = app.ui.language; + let t = theme::Tokens::get(ui.ctx()); + let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d + .fields + .iter() + .filter_map(|f| match f { + Field::Choice { key, label, options } => Some((*key, *label, options.clone())), + _ => None, + }) + .collect(); + for (key, label, options) in choices { + ui.add_space(4.0); + ui.label(egui::RichText::new(lang.tr(label)).strong()); + egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + // Column by column, as Excel fills them. + let rows = options.len().div_ceil(2); + egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for r in 0..rows { + for c in 0..2 { + if let Some((value, text)) = options.get(c * rows + r) { + if ui.radio(cur == *value, lang.tr(text)).clicked() { + d.set_value(key, json!(value)); + } + } else { + ui.label(""); + } + } + ui.end_row(); + } + }); + }); + } + ui.add_space(8.0); + // In the same columns as the options above. + egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { + egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + ui.end_row(); + }); + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + // Excel's Paste Link pastes references to the copied cells straight away. + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + d.values.insert("what".into(), json!("all")); + d.values.insert("operation".into(), json!("none")); + d.values.insert("link".into(), json!(true)); + *confirm = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }); + }); +} + +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +pub(super) 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, + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs new file mode 100644 index 000000000..d6bc92daa --- /dev/null +++ b/crates/ui-egui/src/dialogs/show.rs @@ -0,0 +1,577 @@ +use super::about::about; +use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::charts::{recommended_charts, select_data}; +use super::command_search::command_search; +use super::cond_format_rules::manage_rules; +use super::data::{advanced_filter_form, data_validation_form, sort}; +use super::find::{find, go_to}; +use super::format_cells::format_cells; +use super::insert_function::insert_function; +use super::macros::macros; +use super::names::{name_manager, paste_name}; +use super::paste::{paste_special, paste_special_shortcuts}; +use super::spelling::spelling; +use super::zoom::zoom; +use super::*; + +/// Builds the command params from form values for dialogs that need translation. +/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. +pub(crate) fn build_params(d: &Dialog) -> Json { + let mut v = Json::Object(d.values.clone()); + match d.name.as_str() { + "advancedFilter" => { + if let Some(m) = v.as_object_mut() { + m.remove("action"); + if d.values.get("action").and_then(Json::as_str) != Some("copy") { + m.remove("copyTo"); + } + if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { + m.remove("criteria"); + } + } + } + "dataValidation" => { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + if let Some(m) = v.as_object_mut() { + if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { + m.remove("formula2"); + } + if matches!(kind, "any" | "list" | "custom") { + m.remove("operator"); + } + if kind == "any" { + m.remove("formula1"); + } + if kind != "list" { + m.remove("dropdown"); + } + } + } + "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!("")); + } + } + } + "cfQuick" => { + let mut rule = Map::new(); + for (k, val) in &d.values { + rule.insert(k.clone(), val.clone()); + } + v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); + v["range"] = json!(pick.applies_to); + } + } + "series" => { + if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().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 +} + +/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. +pub fn show(app: &mut SheetApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); + 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, + "zoom" => 220.0, + "pasteSpecial" => 480.0, + _ => 380.0, + }; + let window = egui::Window::new(app.ui.language.tr(&d.title)) + .id(egui::Id::unique("dialog").with(&d.name)) + .collapsible(false) + .resizable(false) + .default_width(width) + // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) + .open(&mut win_open); + let window = match d.name.as_str() { + "insertPicture" => window.default_height(240.0), + "advancedFilter" => window.default_height(0.0), + _ => 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), + "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), + "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), + "find" => find(app, ui, &mut d), + "goTo" => go_to(app, ui, &mut d, &mut confirm), + "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), + "sort" => sort(app, ui, &mut d, &mut confirm), + "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &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), + "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), + "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(app, 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::>()))) + } 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(std::ffi::OsStr::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 = 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 { + // A bare name has no folder: written as typed it would land in the + // process's working directory (`/` from the Finder, the install folder on + // Windows), never where the user looks. The picker chooses the folder. + let pick = app.services.pick_save.as_ref(); + let target = if path.is_empty() { + pick.and_then(|f| f(&format!("Copy.{fmt}"))) + } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { + f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{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"))); + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); + ui.horizontal(|ui| { + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + 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 = 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); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &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)); + } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } else if confirm { + open = false; + } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } + 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); + } +} + +pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { + ui.add_space(8.0); + // A row as tall as the buttons: a right-to-left layout straight in the window takes all the + // height it is offered, which stretched every form dialog to the screen's height. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let lang = crate::i18n::current(ui.ctx()); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("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); +} + +/// Gives keyboard focus to the first text or number field the first time a form is drawn. A +/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and +/// Enter sets the width, as in Excel. +/// Keeps asking until the field really has focus: a new window's first frame is an invisible +/// sizing pass where a focus request is ignored. +fn take_first_focus(d: &mut Dialog, r: &egui::Response) { + if !d.focus_first_field { + return; + } + if !r.has_focus() { + r.request_focus(); + return; + } + // Focus has landed, and the field is now a text box: select its whole value so typing + // replaces it (the field's own select-on-focus fired before it was a text box). + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); + state.cursor.set_char_range(Some(all)); + state.store(&r.ctx, r.id); + d.focus_first_field = false; +} + +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + +/// Draws a dialog's `fields`, editing its values. +pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { + // Field labels, choices and notes are English source strings, translated as they are drawn. + let lang = crate::i18n::current(ui.ctx()); + egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for f in d.fields.clone() { + match f { + Field::Text { key, label } => { + ui.vertical(|ui| { + let label = ui.label(lang.tr(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)).labelled_by(label.id) + } else { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) + }; + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(s)); + } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } + }); + } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + let label = ui.label(lang.tr(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)).labelled_by(label.id).changed() + { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, lang.tr("Show password")); + }); + } + Field::Number { key, label } => { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(label)); + let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); + // Typed values count straight away: otherwise Enter or OK in the same frame + // confirms the old value, because a number field only commits when it loses focus. + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(n)); + } + }); + } + Field::Choice { key, label, options } => { + ui.vertical(|ui| { + let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if d.name == "dataValidation" { + let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); + egui::ComboBox::from_id_salt(("dataValidation", key)) + .selected_text(selected) + .width(300.0) + .show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + }) + .response + .labelled_by(field_label.id); + } else { + for (val, text) in options { + if ui.radio(cur == val, lang.tr(text)).clicked() { + d.set_value(key, 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, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + Field::Note(t) => { + // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a + // context separator, so such a note is shown as it is. + let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; + ui.label(egui::RichText::new(text).small()); + } + } + ui.end_row(); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/spelling.rs b/crates/ui-egui/src/dialogs/spelling.rs new file mode 100644 index 000000000..2a53b6d8a --- /dev/null +++ b/crates/ui-egui/src/dialogs/spelling.rs @@ -0,0 +1,54 @@ +use super::*; + +pub(super) 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(app.ui.language.tr_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(crate::theme::Tokens::get(ui.ctx()).danger)); + let sugg: Vec = + 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; + } + } + }); +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs new file mode 100644 index 000000000..a81b03b35 --- /dev/null +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -0,0 +1,39 @@ +use super::show::ok_cancel; +use super::*; + +/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. +const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; + +/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. +pub(super) fn zoom_preset(percent: f64) -> &'static str { + ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") +} + +/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and +/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage +/// selects Custom; Fit selection zooms so the selected cells fill the grid. +pub(super) fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); + ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); + for (val, text) in ZOOM_PRESETS { + if ui.radio(cur == val, text).clicked() { + d.set_value("preset", json!(val)); + } + } + if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { + d.set_value("preset", json!("fit")); + } + ui.horizontal(|ui| { + if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { + d.set_value("preset", json!("custom")); + } + // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. + let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; + if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { + d.set_value("percent", json!(percent)); + } + ui.label("%"); + }); + ok_cancel(ui, confirm, open); +}