gridcraft/crates/engine/src/cmd/extra.rs

1115 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! More ribbon commands: Chart Design and Format, object arrangement, workbook views, table
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
// Chart Design / Format
cmd!(
"chart.addElement",
"Add Chart Element",
["Chart Design", "Chart Layouts"],
None,
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}",
has_doc,
add_element
),
cmd!("chart.quickLayout", "Quick Layout", ["Chart Design", "Chart Layouts"], None, "{chart?, layout: 1..6}", has_doc, quick_layout),
cmd!(
"chart.changeColors",
"Change Colors",
["Chart Design", "Chart Styles"],
None,
"{chart?, palette: colorful|monochrome|accent1..accent6 | colors: [hex…]}",
has_doc,
change_colors
),
cmd!(
"chart.selectData",
"Select Data…",
["Chart Design", "Data"],
None,
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
has_doc,
select_data
),
cmd!("chart.changeType", "Change Chart Type…", ["Chart Design", "Type"], None, "{chart?, type, subtype?}", has_doc, |s, p| s
.execute("chart.set", p.clone())),
cmd!(
"chart.move",
"Move Chart…",
["Chart Design", "Location"],
None,
"{chart?, sheet?: name (moves to that sheet), at?: \"B2\"}",
has_doc,
move_chart
),
cmd!(
"chart.formatSelection",
"Format Selection",
["Format", "Current Selection"],
None,
"{chart?, series?: index, color?: hex}",
has_doc,
format_series
),
cmd!(
"chart.shapeFill",
"Shape Fill",
["Format", "Shape Styles"],
None,
"{chart? | kind: shape, id?, series?, color: hex}",
has_doc,
shape_fill
),
cmd!("chart.shapeOutline", "Shape Outline", ["Format", "Shape Styles"], None, "{kind: shape, id?, color: hex}", has_doc, shape_outline),
// Arrange
cmd!(
"arrange.bringForward",
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
cmd!(
"arrange.sendBackward",
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
cmd!(query "arrange.selectionPane", "Selection Pane", ["Page Layout", "Arrange"], None, "{} → objects on the sheet in stacking order", has_doc, selection_pane),
cmd!(
"arrange.align",
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
cmd!(
"arrange.group",
"Group",
["Page Layout", "Arrange"],
None,
"{kind, ids} (moves objects together: aligns their anchors to the first)",
has_doc,
|s, p| align(s, &super::format::merge_json(p.clone(), &json!({"align": "left"})))
),
cmd!(
"arrange.rotate",
"Rotate",
["Page Layout", "Arrange"],
None,
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
has_doc,
rotate
),
// Views
cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{sheet?} → {view} (per sheet, saved with the file)", has_doc, |s, p| set_view(
s,
p,
SheetViewMode::Normal
)),
cmd!(
noundo "view.pageLayout",
"Page Layout",
["View", "Workbook Views"],
None,
"{sheet?} → {view}: pages on paper with rulers; headers and footers are edited in place (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageLayout)
),
cmd!(
noundo "view.pageBreakPreview",
"Page Break Preview",
["View", "Workbook Views"],
None,
"{sheet?} → {view} (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageBreakPreview)
),
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window),
cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()),
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
// Table tools
cmd!("table.resize", "Resize Table", ["Table Design", "Properties"], None, "{table?, range: \"A1:F20\"}", has_doc, table_resize),
cmd!("table.removeDuplicates", "Remove Duplicates", ["Table Design", "Tools"], None, "{table?, columns?}", has_doc, |s, p| s
.execute("data.removeDuplicates", p.clone())),
// Get & Transform
cmd!(
"data.fromTextCsv",
"From Text/CSV",
["Data", "Get & Transform Data"],
None,
"{path | text, delimiter?: \",\", sheet?: new sheet name} → imports into a new sheet as a table",
has_doc,
from_csv
),
cmd!(
"data.getData",
"Get Data",
["Data", "Get & Transform Data"],
None,
"{path | text} (CSV/TSV/JSON array of objects) → new sheet table",
has_doc,
from_csv
),
cmd!(
"data.fromTableRange",
"From Table/Range",
["Data", "Get & Transform Data"],
None,
"{range?} → copies the table/range to a new sheet as a table",
has_doc,
from_table_range
),
cmd!(query "data.queriesConnections", "Queries & Connections", ["Data", "Queries & Connections"], None, "{} → imported tables", has_doc, queries),
// Automate
cmd!(noundo "automate.recordActions", "Record Actions", ["Automate", "Scripting Tools"], None, "{stop?: bool, name?: script name when stopping}", has_doc, record),
cmd!(
"automate.newScript",
"New Script",
["Automate", "Scripting Tools"],
None,
"{name, commands: [{command, params}] }",
has_doc,
new_script
),
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
// Cell controls, themes, borders, styles
cmd!(
"insert.checkbox",
"Checkbox",
["Insert", "Cell Controls"],
None,
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
has_doc,
checkbox
),
cmd!("cell.toggleCheckbox", "Toggle Checkbox", [], Some("Space"), "{cell?}", has_doc, toggle_checkbox),
cmd!("pageLayout.themeColors", "Colors", ["Page Layout", "Themes"], None, "{name: theme name | colors: [12 hex]}", has_doc, theme_colors),
cmd!("pageLayout.themeFonts", "Fonts", ["Page Layout", "Themes"], None, "{major, minor}", has_doc, theme_fonts),
cmd!(
"pageLayout.themeEffects",
"Effects",
["Page Layout", "Themes"],
None,
"{name} (chart effects preset; stored with the theme name)",
has_doc,
|_, _| ok()
),
cmd!(
"pageLayout.background",
"Background",
["Page Layout", "Page Setup"],
None,
"{path? | clear?: bool} (sheet background picture)",
has_doc,
background
),
cmd!(
"home.drawBorder",
"Draw Border",
["Home", "Font"],
None,
"{range, edge: top|bottom|left|right|grid, style?, color?}",
has_doc,
draw_border
),
cmd!(
"home.newCellStyle",
"New Cell Style…",
["Home", "Styles"],
None,
"{name, fromCell?: \"A1\" (default: active cell)}",
has_doc,
new_cell_style
),
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
cmd!(query "formulas.dateTime", "Date & Time", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "DateTime"}))),
cmd!(query "formulas.lookupReference", "Lookup & Reference", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Lookup"}))),
cmd!(query "formulas.mathTrig", "Math & Trig", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "MathTrig"}))),
cmd!(query "formulas.moreFunctions", "More Functions", ["Formulas", "Function Library"], None, "{category?: Statistical|Engineering|Information|Database|Compatibility|Web|Cube}", always, |s, p| s.execute("formulas.functions", json!({"category": str_param(p, "category").unwrap_or("Statistical")}))),
cmd!(
"formulas.useInFormula",
"Use in Formula",
["Formulas", "Defined Names"],
None,
"{name} (inserts the name into the cell being edited)",
has_doc,
use_in_formula
),
cmd!(query "formulas.watchWindow", "Watch Window", ["Formulas", "Formula Auditing"], None, "{add?: \"Sheet1!B5\", remove?: \"…\"} → watched cells with current values", has_doc, watch_window),
]
}
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet"))
}
fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?;
f(c)?;
Ok(json!({"chart": c.id}))
})
}
fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
let el = str_param(p, "element").unwrap_or("title").to_string();
let on = bool_param(p, "on").unwrap_or(true);
let pos = str_param(p, "position").map(str::to_string);
with_chart(s, p, |c| {
match el.as_str() {
"title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) },
"legend" => {
c.legend = if !on {
LegendPos::None
} else {
match pos.as_deref() {
Some("top") => LegendPos::Top,
Some("left") => LegendPos::Left,
Some("right") => LegendPos::Right,
_ => LegendPos::Bottom,
}
}
}
"dataLabels" => c.data_labels = on,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
}
"smooth" => {
for s in &mut c.series {
s.smooth = on;
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
})
}
fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
let n = u32_param(p, "layout").unwrap_or(1);
with_chart(s, p, |c| {
let (legend, labels, grid, axes) = match n {
2 => (LegendPos::Top, true, false, false),
3 => (LegendPos::Bottom, false, true, false),
4 => (LegendPos::Bottom, true, false, false),
5 => (LegendPos::None, false, true, true),
6 => (LegendPos::Right, false, true, true),
_ => (LegendPos::Right, false, true, false),
};
c.legend = legend;
c.data_labels = labels;
c.gridlines = grid;
if axes {
c.x_title.get_or_insert_with(|| "Axis Title".into());
c.y_title.get_or_insert_with(|| "Axis Title".into());
} else {
c.x_title = None;
c.y_title = None;
}
Ok(())
})
}
fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
let explicit: Option<Vec<Color>> =
p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect());
let palette = str_param(p, "palette").unwrap_or("colorful").to_string();
with_chart(s, p, |c| {
for (i, se) in c.series.iter_mut().enumerate() {
se.color = match &explicit {
Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(),
_ => match palette.as_str() {
"monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))),
p if p.starts_with("accent") => {
let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1);
Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800)))
}
_ => None,
},
};
}
Ok(())
})
}
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let target = match p.get("sheet").and_then(Json::as_str) {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
chart.anchor.cell = a;
}
let dest = target.unwrap_or(si);
cx.sheet_mut(dest)?.charts.push(chart);
Ok(Json::Null)
})
}
fn format_series(s: &mut Session, p: &Json) -> Result<Json> {
let idx = u32_param(p, "series").unwrap_or(0) as usize;
let color = str_param(p, "color").and_then(Color::from_hex);
with_chart(s, p, |c| {
if let Some(se) = c.series.get_mut(idx) {
se.color = color;
}
Ok(())
})
}
fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
return with_shape(s, p, |sh| sh.fill = color);
}
format_series(s, p)
}
fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
with_shape(s, p, |sh| sh.line = color)
}
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let id = u32_param(p, "id");
edit(s, |cx| {
let shapes = &mut cx.sheet_mut(si)?.shapes;
let sh = match id {
Some(id) => shapes.iter_mut().find(|x| x.id == id),
None => shapes.last_mut(),
}
.ok_or_else(|| bad("shape", "no such shape"))?;
f(sh);
Ok(Json::Null)
})
}
fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet;
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
}
let item = v.remove(i);
let j = match (forward, extreme) {
(true, true) => v.len(),
(false, true) => 0,
(true, false) => (i + 1).min(v.len()),
(false, false) => i.saturating_sub(1),
};
v.insert(j, item);
}
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
match kind.as_str() {
"chart" => {
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
reorder(&mut sh.charts, i, forward, extreme);
}
}
"image" => {
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
reorder(&mut sh.images, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
}
}
}
Ok(Json::Null)
})
}
fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
match kind {
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
}
}
fn align(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
let shc = sheet.clone();
anchors_of(sheet, &kind)
.into_iter()
.filter(|(id, _)| ids.contains(id))
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
.collect()
};
if geo.len() < 2 {
return Ok(Json::Null);
}
let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min);
let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max);
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
let shc = sheet.clone();
for (id, a) in anchors_of(sheet, &kind) {
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
let (mut x, mut y) = (g.1, g.2);
match how.as_str() {
"left" => x = left,
"right" => x = right - g.3 as f64,
"center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0,
"top" => y = top,
"bottom" => y = bottom - g.4 as f64,
_ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0,
}
let c = CellRef::new(shc.row_at(y), shc.col_at(x));
a.cell = c;
a.dx = (x - shc.col_left(c.col)) as f32;
a.dy = (y - shc.row_top(c.row)) as f32;
}
Ok(Json::Null)
})
}
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(si) {
sh.view = mode;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"view": mode.as_str()}))
}
fn ruler(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.hide_ruler);
s.hide_ruler = !on;
Ok(json!({"on": on}))
}
const VIEWS_NAME: &str = "_sc_custom_views";
const SCRIPTS_NAME: &str = "_sc_scripts";
const WATCH_NAME: &str = "_sc_watch";
/// Workbook-level JSON stored in a hidden defined name (round-trips through XLSX).
fn stash_get(wb: &Workbook, key: &str) -> Json {
wb.names
.iter()
.find(|n| n.name == key)
.and_then(|n| n.formula.strip_prefix('"').and_then(|t| t.strip_suffix('"')).map(|t| t.replace("\"\"", "\"")))
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(json!({}))
}
fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
let text = v.to_string();
let formula = format!("\"{}\"", text.replace('"', "\"\""));
wb.names.retain(|n| n.name != key);
wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true });
}
/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current
/// list when they swap in another snapshot of the workbook.
pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>) {
let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned();
let keep = find(from);
if keep == find(to) {
return;
}
let wb = std::sync::Arc::make_mut(to);
wb.names.retain(|n| n.name != WATCH_NAME);
wb.names.extend(keep);
}
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_get(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
} else if let Some(name) = str_param(p, "show") {
let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?;
if let Some(si) = v["sheet"].as_u64() {
s.execute("sheet.activate", json!({"sheet": si}))?;
}
if let Some(sel) = v["selection"].as_str() {
s.execute("selection.set", json!({"range": sel}))?;
}
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
}
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
}
Ok(stash_get(&s.doc()?.wb, VIEWS_NAME))
}
fn split(s: &mut Session, _: &Json) -> Result<Json> {
let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some());
if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) }
}
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
copy.selection = d.selection.clone();
let i = s.add_document(copy);
Ok(json!({"index": i}))
}
fn hide_window(s: &mut Session, _: &Json) -> Result<Json> {
s.hidden_windows.push(s.active_index());
let n = s.documents().len();
if n > 1 {
let next = (0..n).find(|i| !s.hidden_windows.contains(i)).unwrap_or(0);
s.set_active(next);
}
ok()
}
fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").map(|v| v as usize).or_else(|| s.hidden_windows.last().copied());
if let Some(i) = i {
s.hidden_windows.retain(|x| *x != i);
s.set_active(i);
}
ok()
}
fn switch_windows(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(
s.documents()
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)}))
.collect(),
))
}
fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
let d = s.doc()?;
let si = d.wb.active_sheet;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let ti = match str_param(p, "table") {
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)),
}
.ok_or_else(|| bad("table.resize", "select a cell in a table"))?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
}
let mut cols = Vec::new();
for (i, h) in headers.iter().enumerate() {
let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() };
let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned();
cols.push(existing.unwrap_or(TableColumn { name, totals: TotalsFn::None, totals_label: None, formula: None }));
}
t.columns = cols;
t.range = r;
cx.structural = true;
Ok(Json::Null)
})
}
fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
let bytes = if let Some(t) = str_param(p, "text") {
t.as_bytes().to_vec()
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
return ok();
};
let text = String::from_utf8_lossy(&bytes);
// JSON array of objects → rows.
let rows: Vec<Vec<Json>> =
if text.trim_start().starts_with('[') {
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
let mut keys: Vec<String> = Vec::new();
for o in &arr {
if let Some(m) = o.as_object() {
for k in m.keys() {
if !keys.contains(k) {
keys.push(k.clone());
}
}
}
}
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
for o in &arr {
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
}
out
} else {
let delim = str_param(p, "delimiter")
.and_then(|d| d.chars().next())
.unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' });
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() };
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
(u.start.row..=u.end.row)
.map(|r| {
(u.start.col..=u.end.col)
.map(|c| match sh.value(CellRef::new(r, c)) {
Value::Number(n) => json!(n),
Value::Bool(b) => json!(b),
Value::Empty => Json::Null,
v => json!(v.display()),
})
.collect()
})
.collect()
};
if rows.is_empty() || rows.len() > 1_048_576 {
return Err(EngineError::Other("Nothing to import.".into()));
}
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
});
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
s.execute("range.setValues", json!({"range": "A1", "values": rows}))?;
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1));
let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true}));
let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()}));
Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w}))
}
fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r);
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
}
fn queries(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
Ok(Json::Array(
d.wb.sheets
.iter()
.flat_map(|sh| {
sh.tables
.iter()
.map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)}))
})
.collect(),
))
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
}
s.recording = Some(s.journal.len());
Ok(json!({"recording": true}))
}
fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string();
let cmds = p.get("commands").cloned().unwrap_or(json!([]));
if !cmds.is_array() {
return Err(bad("automate.newScript", "`commands` must be an array"));
}
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
all[name.as_str()] = cmds.clone();
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(json!({"name": name}))
})
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
}
Ok(Json::Array(list))
}
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
let mut n = 0;
for st in steps.iter().take(100_000) {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
s.execute(id, st["params"].clone())?;
n += 1;
}
Ok(json!({"ran": n}))
}
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
if let Some(o) = all.as_object_mut() {
o.remove(&name);
}
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(Json::Null)
})
}
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
for r in &ranges {
if r.count() > 100_000 {
return Err(bad("insert.checkbox", "range too large"));
}
for c in r.iter() {
let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = cx.wb.styles.derive(st, |s| {
s.num_fmt = NumFmt::new("checkbox");
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
Ok(Json::Null)
})
}
fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let cells: Vec<CellRef> = match cell_param_on(s, p, "cell", si)? {
Some(c) => vec![c],
None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::<Vec<_>>()).collect(),
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& cell.formula.is_none()
{
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}
Ok(Json::Null)
})
}
fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
let mut params = p.clone();
if params.get("name").is_none() {
params["name"] = json!(s.doc()?.wb.theme.name.clone());
}
s.execute("pageLayout.theme", params)
}
fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
let name = s.doc()?.wb.theme.name.clone();
let colors: Vec<String> = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect();
s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")}))
}
fn background(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "clear").unwrap_or(false) {
let si = s.doc()?.wb.active_sheet;
return edit(s, |cx| {
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
Ok(Json::Null)
});
}
let mut params = p.clone();
params["alt"] = json!("Sheet background");
params["at"] = json!("A1");
s.execute("insert.picture", params)
}
fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
let edge = str_param(p, "edge").unwrap_or("grid");
let preset = match edge {
"top" => "top",
"bottom" => "bottom",
"left" => "left",
"right" => "right",
"outline" => "outside",
_ => "all",
};
let mut params = p.clone();
params["preset"] = json!(preset);
s.execute("home.borders", params)
}
fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active);
let style = d.wb.styles.get(sh.style_id(c)).clone();
edit(s, |cx| {
cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
cx.wb.cell_styles.push((name.clone(), style.clone()));
Ok(json!({"name": name}))
})
}
fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
let mut seen: Vec<String> = Vec::new();
for (id, p) in s.journal.iter().rev() {
if id != "cell.set" {
continue;
}
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
for f in e.functions() {
if !seen.iter().any(|x| x == f) {
seen.push(f.to_string());
}
}
}
if seen.len() >= 10 {
break;
}
}
Ok(json!(seen))
}
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
ok()
}
fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
let mut list: Vec<String> =
stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
let mut changed = false;
if let Some(a) = str_param(p, "add") {
if !list.iter().any(|x| x == a) {
list.push(a.to_string());
}
changed = true;
}
if let Some(r) = str_param(p, "remove") {
list.retain(|x| x != r);
changed = true;
}
if changed {
let v = json!(list.clone());
let d = s.doc_mut()?;
// The watch list is view state: don't add an undo step.
let wb = std::sync::Arc::make_mut(&mut d.wb);
stash_set(wb, WATCH_NAME, &v);
}
let d = s.doc()?;
let rows: Vec<Json> = list
.iter()
.map(|w| {
let (sheet, body) = split_sheet(w);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet);
let c = CellRef::parse(body).unwrap_or_default();
let sh = d.wb.sheet(si);
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
})
.collect();
Ok(Json::Array(rows))
}