Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away

> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-05 · **Change:** minor (CJK fallback text shares the Latin baseline; earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-10 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: CJK fallback text shares the Latin baseline; earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)

GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
Expand Down Expand Up @@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-11-09 | XLSX whole-object picture and shape click links retain targets, target modes, tooltips and actions through repeated saves (#3665); optional metadata supports older JSON objects; UI activation is separate. |
| 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) |
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
Expand Down Expand Up @@ -270,6 +271,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Retain drawing click-link preservation and destination-deletion handling alongside current CJK baseline, legacy-array and regional-format updates; estimates unchanged. |
| 2026-11-09 | minor | Preserve picture/shape click hyperlink metadata through XLSX saves; deleting a destination removes its links so a recreated sheet cannot inherit them (#3665). |
| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) |
| 2026-11-05 | minor | Reconcile upstream form-control progress with the CJK baseline update |
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn parse_points(t: &str) -> Vec<[f32; 2]> {
t.split(';').filter_map(|pt| pt.split_once(',').and_then(|(x, y)| Some([x.parse().ok()?, y.parse().ok()?]))).collect()
}

/// Insert an original ink stroke as an unlinked shape anchored to the selected cells.
fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let pts: Vec<[f64; 2]> = p
.get("points")
Expand Down Expand Up @@ -80,6 +81,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
let id = d.wb.next_object_id();
let shape = Shape {
hyperlink: None,
math: Default::default(),
id,
kind: ShapeKind::Ink,
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/equation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub fn describe(latex: &str, e: &MathError) -> String {
}
}

/// Insert an equation shape with its source and no initial click link.
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.equation";
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
Expand Down Expand Up @@ -87,6 +88,7 @@ fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
let id = d.wb.next_object_id();
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
let mut shape = Shape {
hyperlink: None,
id,
kind: ShapeKind::Equation,
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/cmd/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
})
}

/// Insert a picture at the requested anchor without an inherited click link.
fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let placement = str_param(p, "placement").unwrap_or("overCells");
if !matches!(placement, "cell" | "overCells") {
Expand Down Expand Up @@ -1116,6 +1117,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
hyperlink: None,
id,
anchor: Anchor {
cell: at,
Expand Down Expand Up @@ -1164,6 +1166,7 @@ fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
Ok(mime)
}

/// Insert the requested shape and initialize its formatting and anchor.
fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
Expand All @@ -1185,6 +1188,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
Color::Theme(4, -250)
});
let shape = Shape {
hyperlink: None,
math: Default::default(),
id,
kind,
Expand Down Expand Up @@ -1825,6 +1829,7 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
})
}

/// Insert a generated icon shape without imported click metadata.
fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("check").to_string();
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
Expand All @@ -1833,6 +1838,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
hyperlink: None,
math: Default::default(),
id,
kind: ShapeKind::Icon,
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/insert/cond_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
})
}

/// Insert a generated icon shape without imported click metadata.
pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("check").to_string();
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
Expand All @@ -326,6 +327,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
hyperlink: None,
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/cmd/insert/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
})
}

/// Insert a picture at the requested anchor without an inherited click link.
pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let placement = str_param(p, "placement").unwrap_or("overCells");
if !matches!(placement, "cell" | "overCells") {
Expand Down Expand Up @@ -67,6 +68,7 @@ pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
hyperlink: None,
id,
anchor: Anchor {
cell: at,
Expand Down Expand Up @@ -115,6 +117,7 @@ fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
Ok(mime)
}

/// Insert the requested shape and initialize its formatting and anchor.
pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
Expand All @@ -139,6 +142,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
Color::Theme(4, -250)
});
let shape = Shape {
hyperlink: None,
id,
kind,
anchor: Anchor {
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/print/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ fn rich_sheet_renders() {
assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj"));
}

/// Print rendering includes charts and pictures at their worksheet anchors.
#[test]
fn charts_and_images_are_drawn() {
let mut s = s();
Expand All @@ -513,6 +514,7 @@ fn charts_and_images_are_drawn() {
let wb = std::sync::Arc::make_mut(&mut d.wb);
let id = wb.next_object_id();
wb.sheet_mut(0).unwrap().images.push(Image {
hyperlink: None,
id,
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
data: png.into(),
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/cmd/sheet/tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ fn edit_doc<R>(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -
crate::cmd::commit(d, f)
}

/// Delete a worksheet and invalidate references and drawing links to its old identity.
pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let i = target_sheet(s, p)?;
let d = s.doc()?;
Expand All @@ -54,6 +55,13 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let keys: Vec<(CellRef, Arc<gridcraft_model::Formula>)> =
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
// Remove the complete link so neither its logical identity nor raw part path can
// accidentally point at a newly inserted sheet after this destination is deleted.
for link in sh.images.iter_mut().map(|i| &mut i.hyperlink).chain(sh.shapes.iter_mut().map(|s| &mut s.hyperlink)) {
if link.as_ref().and_then(|l| l.internal_sheet.as_deref()).is_some_and(|sheet| sheet.eq_ignore_ascii_case(&name)) {
*link = None;
}
}
for (c, f) in keys {
// Only formulas whose tree changes are rewritten; the others keep their text.
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order)).filter(|ne| ne != e));
Expand Down Expand Up @@ -95,6 +103,7 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
ok()
}

/// Rename a worksheet and update formula, chart and drawing-link destinations in the same undo step.
pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
Expand All @@ -118,6 +127,11 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
}
}
for link in sh.images.iter_mut().filter_map(|i| i.hyperlink.as_mut()).chain(sh.shapes.iter_mut().filter_map(|s| s.hyperlink.as_mut())) {
if link.internal_sheet.as_deref().is_some_and(|sheet| sheet.eq_ignore_ascii_case(&old)) {
link.internal_sheet = Some(name.clone());
}
}
for ch in sh.charts.iter_mut() {
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/cmd/wordart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ fn insert_word_art(s: &mut Session, p: &Json) -> Result<Json> {
text: text.clone(),
format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() },
math: Default::default(),
hyperlink: None,
};
apply("insert.wordArt", &mut shape, Some(style), p)?;
// Default box: the longest line at the font size (about 0.62 em per character) plus padding.
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/io/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,12 @@ fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
assert!(to_html(&ok, 0).is_ok());
}

/// HTML export embeds picture bytes in data URIs.
#[test]
fn html_export_embeds_images_as_data_uris() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image {
hyperlink: None,
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() },
data: vec![1, 2, 3].into(),
Expand All @@ -125,13 +127,15 @@ fn html_export_embeds_images_as_data_uris() {
assert!(!to_html(&Workbook::new(), 0).unwrap().contains("<img"), "no images, no <img>");
}

/// HTML picture placement follows the worksheet row and column geometry.
#[test]
fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
sh.images.push(gridcraft_model::Image {
hyperlink: None,
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() },
data: vec![1].into(),
Expand All @@ -146,11 +150,13 @@ fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}");
}

/// Build a synthetic workbook with one picture for export-placement tests.
fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0));
sh.images.push(gridcraft_model::Image {
hyperlink: None,
id: 1,
anchor: gridcraft_model::Anchor {
cell: CellRef::parse(anchor).unwrap(),
Expand Down
Loading