mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 02:51:33 +00:00
A sheet holds form controls as floating objects of a new kind (ObjKind::Control): buttons that run a macro or one command, and sliders and spin buttons that hold a whole number from 0 to 30000 and write it to a linked cell. They take part in everything other objects do: ids from the workbook sequence, object.move, object.delete and object.setAnchorMode, the Selection Pane and Bring Forward / Send Backward, anchors that follow row and column edits, fresh ids on a copied sheet, and links that follow inserts, deletes, cut-moves, sheet renames and sheet deletes like chart series. Commands (cmd/controls.rs): insert.button, insert.slider, insert.spinButton, control.format, control.assign, control.setValue, control.press and the control.list query. Each numeric rule (0 <= min < max <= 30000, 1 <= step <= max - min, page >= step for sliders) is refused with its own message, and values are clamped and snapped to the step. control.setValue writes the linked cell through cell.set inside one undo group, so dependents recalculate and one Undo restores both; every commit moves linked controls to what their cells hold. control.press runs a button's macro through macro.run, or its command through the macro policy with the document's trust, so a button in a workbook opened from a file asks before it runs anything. Like a macro step, the command runs inside the scripts' file sandbox unless it is an approved file command. Macros never press controls, and macro.rename keeps buttons pointing at the renamed macro. macro.run, macro.check and the debugger take `output`, which replaces the macro's output anchor for that run (a button that sends the output elsewhere). The policy and the approval go by the stored macro; the footprint, the conflict rule and the per-step check work on the cells `output` names. A value that isn't reference text of at most 1,024 characters, or names no cell, is refused before anything runs.
368 lines
17 KiB
Rust
368 lines
17 KiB
Rust
//! 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::{
|
||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
|
||
Workbook,
|
||
};
|
||
use serde_json::{Value as Json, json};
|
||
|
||
use super::{
|
||
CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc,
|
||
ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
|
||
};
|
||
|
||
mod cells;
|
||
mod chart;
|
||
mod data;
|
||
mod objects;
|
||
mod scripts;
|
||
mod window;
|
||
|
||
pub(crate) use cells::is_checkbox_cell;
|
||
use cells::{background, checkbox, draw_border, new_cell_style, recently_used, theme_colors, theme_fonts, toggle_checkbox, use_in_formula};
|
||
use chart::{add_element, change_colors, format_series, move_chart, quick_layout, select_data};
|
||
use data::{from_csv, from_table_range, queries, table_resize};
|
||
use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline};
|
||
pub(crate) use scripts::has_scripts;
|
||
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
|
||
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
|
||
pub(crate) use window::{keep_watch_list, window_param};
|
||
|
||
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|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||
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|slicer|control, id, toFront?: bool}",
|
||
has_doc,
|
||
|s, p| arrange(s, p, true)
|
||
),
|
||
cmd!(
|
||
"arrange.sendBackward",
|
||
"Send Backward",
|
||
["Page Layout", "Arrange"],
|
||
None,
|
||
"{kind: chart|image|shape|slicer|control, 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|slicer|control, 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} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
|
||
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, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window),
|
||
cmd!(
|
||
noundo "view.arrangeAll",
|
||
"Arrange All",
|
||
["View", "Window"],
|
||
None,
|
||
"{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first",
|
||
has_doc,
|
||
arrange_all
|
||
),
|
||
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window),
|
||
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window),
|
||
cmd!(
|
||
noundo "view.switchWindows",
|
||
"Switch Windows",
|
||
["View", "Window"],
|
||
None,
|
||
"{window?, doc?} → open windows [{window, doc, index, title, dirty, active, hidden}]; with `window` (an id from the list), or `doc` (a workbook id: its most recent window), shows and activates that window first",
|
||
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, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
|
||
has_doc,
|
||
run_script
|
||
),
|
||
cmd!(
|
||
noundo "automate.trustScripts",
|
||
"Trust Scripts",
|
||
["Automate", "Scripting Tools"],
|
||
None,
|
||
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
|
||
has_doc,
|
||
trust_scripts
|
||
),
|
||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||
cmd!(
|
||
noundo "automate.stopAction",
|
||
"Stop Action",
|
||
[],
|
||
None,
|
||
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
|
||
has_doc,
|
||
stop_action
|
||
),
|
||
// 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),
|
||
]
|
||
}
|
||
|
||
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!({}))
|
||
}
|
||
|
||
/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty.
|
||
fn stash_map(wb: &Workbook, key: &str) -> Json {
|
||
let v = stash_get(wb, key);
|
||
if v.is_object() { v } else { 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 });
|
||
}
|
||
|
||
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
|
||
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
|
||
|
||
/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null).
|
||
fn object_params(p: &Json) -> Json {
|
||
Json::Object(p.as_object().cloned().unwrap_or_default())
|
||
}
|