Merge origin/main (3379f12a) into the Python cells branch

Main gained #470 (floating objects) and #501 (Solver Sensitivity report).
Conflicts:
- cmd/edit.rs, edit/paste_special.rs, sheet/tabs.rs: #470 adds rewrite_cell_links for
  chart references beside this branch's rewrite_python_refs; every structural edit,
  sheet rename and sheet delete runs both, the Python one last where it can refuse
  the edit (the edit then changes nothing).
- cmd/sheet.rs: rewrite() keeps this branch's Result with main's doc comment.
- model/src/sheet.rs and engine tests.rs: both sides' additions kept.
- ROADMAP.md: header combined, both sides' rows kept.
Main's new code writes no cells, so no Python handling was needed beyond these.
This commit is contained in:
prxnvt
2026-11-02 04:01:07 -05:00
56 changed files with 3532 additions and 386 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells stored and kept in files; 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-02 · **Change:** minor (Python cells stored and kept in files; 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.
@@ -136,6 +136,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
@@ -253,6 +255,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Progress log: Python cells as data |
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
+20 -27
View File
@@ -195,8 +195,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let objs: Vec<Anchor> = sh.object_anchors().copied().collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
@@ -242,33 +241,22 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
// Re-anchor floating objects now that the row heights are final. `objs` was collected in
// `object_anchors` order, which `object_anchors_mut` repeats.
// Only objects anchored inside the sorted block move: within its lines on the sort axis
// and within its span on the other axis (a picture beside the block stays put).
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 {
continue;
}
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
let new_anchors: Vec<Anchor> = objs
.into_iter()
.zip(descs)
.map(|(a, desc)| {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) }
})
.collect();
for (a, n) in shm.object_anchors_mut().zip(new_anchors) {
*a = n;
}
for (c, _) in moved {
cx.changed.push((sheet, c));
@@ -284,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body, and
/// keeps its size when the sort turns its lines around (its bottom line now sorts before its top
/// line), since the lines then enclose nothing to stretch it between.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
@@ -302,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
// An extent that is not positive means the sort put the bottom-right line before the
// top-left one on that axis: the object keeps its old size there.
let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) };
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
+1 -1
View File
@@ -35,7 +35,7 @@ use go_to::go_to_special;
#[cfg(test)]
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
use paste_special::paste_special;
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_python_refs};
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
pub fn specs() -> Vec<CommandSpec> {
@@ -391,9 +391,38 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
n.formula = gridcraft_formula::print(&new);
}
}
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
}
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
/// name and bubble sizes, and the source block it was made from). Every edit that moves
/// references calls it: inserting and deleting lines and cells and cut-moves (through
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
/// block that no longer exists is dropped.
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
for si in 0..wb.sheets.len() {
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(sh) = wb.sheet_mut(si) else { continue };
let fix = |text: &str| -> String {
match gridcraft_formula::parse(text) {
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
Err(_) => text.to_string(),
}
};
for ch in sh.charts.iter_mut() {
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
}
}
}
}
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
+3 -3
View File
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toFront?: bool}",
"{kind: chart|image|shape|slicer, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, id, toBack?: bool}",
"{kind: chart|image|shape|slicer, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
+11
View File
@@ -62,6 +62,11 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
reorder(&mut sh.images, i, forward, extreme);
}
}
"slicer" => {
if let Some(i) = sh.slicers.iter().position(|c| c.id == id) {
reorder(&mut sh.slicers, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
@@ -85,6 +90,11 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
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)}));
}
// Slicers paint above the other objects.
for c in &sh.slicers {
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
@@ -92,6 +102,7 @@ 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(),
"slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
}
}
+55 -47
View File
@@ -5,8 +5,8 @@ use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
Workbook,
Formula, Hyperlink, Image, LegendPos, ObjKind, ObjRef, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn,
TotalsFn, Underline, Workbook,
};
use serde_json::{Value as Json, json};
@@ -146,14 +146,22 @@ pub fn specs() -> Vec<CommandSpec> {
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer, id}", has_doc, delete_object),
cmd!(
"object.move",
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
has_doc,
move_object
),
cmd!(
"object.setAnchorMode",
"Object Properties",
[],
None,
"{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}",
"{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
@@ -928,10 +936,10 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
c.anchor.width = w.clamp(20.0, size_cap(c.anchor.width)) as f32;
}
if let Some(h) = f64_param(p, "height") {
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
c.anchor.height = h.clamp(20.0, size_cap(c.anchor.height)) as f32;
}
if let Some(v) = f64_param(p, "dx") {
c.anchor.dx = v as f32;
@@ -1218,51 +1226,62 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The floating object a `{kind, id}` command names on the active sheet. Unknown kinds and
/// objects are errors, so a stale id never succeeds silently.
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape or slicer"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let o = ObjRef::new(kind, id);
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
return Err(bad(cmd, "no such object"));
}
Ok((sheet, o))
}
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
let (sheet, o) = object_param(s, p, "object.delete")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
cx.sheet_mut(sheet)?.remove_object(o);
Ok(json!({"deleted": o}))
})
}
/// Largest offset (points) `object.move` accepts from the anchor cell.
const MAX_OBJECT_OFFSET: f64 = 100_000.0;
/// Largest width or height (points) `object.move` and `chart.set` give an object that is not
/// already larger: see [`size_cap`].
const MAX_OBJECT_SIZE: f64 = 10_000.0;
/// The most a command may make a dimension that is `current` now: [`MAX_OBJECT_SIZE`], or the
/// current size when a file made the object larger (the XLSX reader accepts anchors far past the
/// limit). Such an object keeps its size when it is moved and shrinks by what a resize asks, but
/// it never grows further, so hostile input is still capped. Never NaN: `f64::max` drops one.
fn size_cap(current: f32) -> f64 {
MAX_OBJECT_SIZE.max(f64::from(current))
}
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
let (sheet, o) = object_param(s, p, "object.move")?;
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.move", "no such object")) };
if let Some(at) = at {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
a.dx = v as f32;
a.dx = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
}
if let Some(v) = f64_param(p, "dy") {
a.dy = v as f32;
a.dy = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
}
if let Some(v) = f64_param(p, "width") {
a.width = v.clamp(1.0, 10000.0) as f32;
a.width = v.clamp(1.0, size_cap(a.width)) as f32;
}
if let Some(v) = f64_param(p, "height") {
a.height = v.clamp(0.0, 10000.0) as f32;
a.height = v.clamp(0.0, size_cap(a.height)) as f32;
}
Ok(Json::Null)
})
@@ -1272,21 +1291,10 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
/// when the sheet is sorted or edited structurally.
fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let (sheet, o) = object_param(s, p, "object.setAnchorMode")?;
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.setAnchorMode", "no such object")) };
a.mode = mode;
Ok(json!({"mode": anchor_mode_name(mode)}))
})
+10 -34
View File
@@ -141,14 +141,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
};
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
// re-fit after the shift measures against the grid the object actually lands on.
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
.charts
.iter()
.map(|o| &o.anchor)
.chain(sh.images.iter().map(|o| &o.anchor))
.chain(sh.shapes.iter().map(|o| &o.anchor))
.map(|a| anchor_corner(sh, a))
.collect();
let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect();
// Line info.
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
let old = std::mem::take(lines);
@@ -193,22 +186,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
// against the post-shift grid first (the re-fit needs its sizes), then written back.
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
let base = new_charts.len() + new_images.len();
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
o.anchor = a;
}
for (o, a) in sh.images.iter_mut().zip(new_images) {
o.anchor = a;
}
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
// Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are
// computed against the post-shift grid first (the re-fit needs its sizes), then written back
// in the same `object_anchors` order the corners were snapshotted in.
let new_anchors: Vec<Anchor> =
sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect();
for (a, n) in sh.object_anchors_mut().zip(new_anchors) {
*a = n;
}
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
Some(c) => {
@@ -231,7 +215,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
/// source for a structural edit on `target`.
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
cx.edits.push((target.to_string(), e.clone()));
let wb = &mut cx.wb;
@@ -245,15 +230,6 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
Err(_) => text.to_string(),
}
};
for ch in sh.charts.iter_mut() {
// The source block moves with its cells; one that was deleted is dropped.
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
}
for sp in sh.sparklines.iter_mut() {
sp.source = fix(&sp.source);
}
+17 -10
View File
@@ -64,6 +64,8 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order))?;
// Chart series into the deleted sheet become #REF!, like formulas.
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
cx.wb.names.retain(|n| n.scope != Some(i));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
@@ -117,17 +119,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
let fix = |t: &str| {
gridcraft_formula::parse(t)
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)))
.unwrap_or_else(|_| t.to_string())
};
for ch in sh.charts.iter_mut() {
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
@@ -136,6 +128,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name));
for n in cx.wb.names.iter_mut() {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
@@ -172,6 +165,20 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sh.name = name;
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
// the largest id).
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
let ids = cx.wb.free_object_ids(count);
if ids.len() < count {
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
}
let mut ids = ids.into_iter();
let mut fresh = || ids.next().unwrap_or(u32::MAX);
sh.charts.iter_mut().for_each(|o| o.id = fresh());
sh.images.iter_mut().for_each(|o| o.id = fresh());
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
+24 -21
View File
@@ -40,15 +40,16 @@ mod model_range;
mod nlp;
mod problem;
mod reports;
mod sensitivity;
use evolutionary::{EvoEnd, evolutionary};
use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp};
pub use model::saved_model;
use lp::{Outcome, SimplexProgram, branch_and_bound, simplex_program, solve_lp};
use model::{Goal, Method, Model, model_params, parse_model, save_model};
pub use model::{saved_model, sensitivity_applies, sensitivity_status};
use model_range::{load_model_cells, reset_model, save_model_cells};
use nlp::{nlp, project};
use problem::{Evaluator, as_num, build_problem};
use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
use reports::{NotOptimal, ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
@@ -78,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Solver…",
["Data", "Analysis"],
None,
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).",
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|sensitivity|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names], skippedReports?: [{report: \"sensitivity\", reason}] (a Sensitivity report left out because the run did not reach an optimum or the values are not one; the solve and the other reports still apply)}. No `objective` → {model: the saved model} (the menu opens the dialog).",
has_doc,
run
),
@@ -87,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Solver Reports",
[],
None,
"{reports: [answer|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names]}. Writes Answer and Limits reports on new sheets before the model's sheet; Limits needs Simplex LP or GRG Nonlinear and no integer constraints. data.solver's result can be passed as is.",
"{reports: [answer|sensitivity|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), skipNotOptimal?: bool (default false: a Sensitivity report for values that are not optimal refuses the call; true leaves it out and writes the others), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names], skippedReports?: [{report, reason}]}. Writes Answer, Sensitivity and Limits reports on new sheets before the model's sheet; Sensitivity and Limits need Simplex LP or GRG Nonlinear and no integer constraints, and Sensitivity a Max or Min objective. data.solver's result can be passed as is.",
has_doc,
report
),
@@ -146,24 +147,13 @@ fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bound
Outcome::Infeasible
} else {
match m.method {
Method::Simplex => match linearize(ev, x0) {
Method::Simplex => match simplex_program(ev, goal, x0) {
Err("nonlinear") | Err("error") => {
not_linear = true;
Outcome::Infeasible
}
Err(_) => Outcome::Limit,
Ok(((cf, f_const), lin)) => {
let mut rows: Vec<(Vec<f64>, Sense, f64)> = lin.iter().zip(&ev.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
let c: Vec<f64> = match goal {
Goal::Max => cf.iter().map(|v| -v).collect(),
Goal::Min => cf.clone(),
Goal::Value(v) => {
rows.push((cf.clone(), Sense::Eq, v - f_const));
vec![0.0; cf.len()]
}
};
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
let mut work = LP_WORK.max(size);
Ok(SimplexProgram { c, rows, mut work, .. }) => {
let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work);
if any_int { branch_and_bound(lo, hi, integer, MAX_NODES_LP, gap, &mut relax).0 } else { relax(lo, hi) }
}
@@ -218,6 +208,14 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
let save = bool_param(&params, "saveModel").unwrap_or(true);
let reports = report_kinds(CMD, &params)?;
let prob = build_problem(&model);
// A Sensitivity report the model has none of (integer, Evolutionary, Value Of) fails the
// call, so it is refused before the solve runs rather than after it.
if let Some(kinds) = &reports
&& kinds.contains(&reports::ReportKind::Sensitivity)
&& let Some(why) = sensitivity::sensitivity_refusal(&model, &prob)
{
return Err(EngineError::Other(why.into()));
}
let d = s.doc()?;
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
let x0: Vec<f64> = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect();
@@ -277,12 +275,14 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
}
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
let changed = keep && solution.is_some();
let built = match (&reports, &solution) {
// A Sensitivity report the solution doesn't support is left out (with its reason), so the
// solve and the other reports still apply.
let (built, skipped) = match (&reports, &solution) {
(Some(kinds), Some(x)) => {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
prepare_reports(s.doc()?, kinds, &inp)?
prepare_reports(s.doc()?, kinds, &inp, NotOptimal::Skip { optimum: sensitivity_status(status) })?
}
_ => Vec::new(),
_ => (Vec::new(), Vec::new()),
};
let count = built.len();
let before = s.doc()?.wb.clone();
@@ -325,6 +325,9 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
if reports.is_some() && solution.is_some() {
out["reports"] = json!(names);
}
if !skipped.is_empty() {
out["skippedReports"] = json!(skipped);
}
Ok(out)
}
+88 -13
View File
@@ -1,10 +1,14 @@
use super::model::Goal;
use super::problem::Evaluator;
use super::{CellRef, EPS};
use super::{CellRef, EPS, LP_WORK};
// ---------------------------------------------------------------- evaluation
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
/// A row of an LP: coefficients, sense and right side.
pub(super) type LpRow = (Vec<f64>, Sense, f64);
/// Objective (gradient, constant) and per-row (gradient, constant).
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
@@ -34,9 +38,10 @@ pub(super) enum Outcome {
Limit,
}
struct Map {
var: usize,
sign: f64,
/// A column of the simplex tableau standing for a variable: `x_var = offset + Σ sign·y`.
pub(super) struct Map {
pub(super) var: usize,
pub(super) sign: f64,
}
struct VariableMap {
@@ -45,6 +50,23 @@ struct VariableMap {
bound_rows: Vec<BoundRow>,
}
/// The final tableau of an optimal LP, for the Sensitivity report.
pub(super) struct FinalTableau {
/// Rows `B⁻¹A | B⁻¹b`.
pub(super) t: Vec<Vec<f64>>,
pub(super) basis: Vec<usize>,
/// The phase-2 reduced costs.
pub(super) obj: Vec<f64>,
pub(super) width: usize,
/// The first artificial column.
pub(super) first_art: usize,
pub(super) cols: Vec<Map>,
/// Per input row: the column that started as its unit column, and whether the row was
/// negated to make its right side non-negative.
pub(super) ident: Vec<usize>,
pub(super) flipped: Vec<bool>,
}
struct Tableau {
rows: Vec<Vec<f64>>,
basis: Vec<usize>,
@@ -82,7 +104,9 @@ fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
VariableMap { columns, offset, bound_rows }
}
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
/// The rows over the tableau's columns, each with a non-negative right side, and per row
/// whether it was negated to get there.
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> (Vec<LpRow>, Vec<bool>) {
let width = columns.len();
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
for (coefficients, sense, rhs) in rows {
@@ -105,7 +129,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
transformed.push((row, sense, rhs));
}
// Rows with a negative right side are negated so every right side is >= 0.
transformed
let flipped = transformed.iter().map(|row| row.2 < 0.0).collect();
let normalized = transformed
.into_iter()
.map(|(row, sense, rhs)| {
if rhs < 0.0 {
@@ -119,7 +144,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
(row, sense, rhs)
}
})
.collect()
.collect();
(normalized, flipped)
}
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
@@ -388,20 +414,33 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
solve_lp_tableau(c, rows, lo, hi, budget).0
}
/// [`solve_lp`], with the final tableau when it is optimal.
pub(super) fn solve_lp_tableau(
c: &[f64],
rows: &[(Vec<f64>, Sense, f64)],
lo: &[f64],
hi: &[f64],
budget: &mut u64,
) -> (Outcome, Option<FinalTableau>) {
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
return (Outcome::Infeasible, None);
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
let mapping = map_variables(c.len(), lo, hi);
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let normalized = normalize_rows(rows, extra, &cols, &offset);
let (normalized, flipped) = normalize_rows(rows, extra, &cols, &offset);
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
// Each row's starting basic column is its unit column.
let ident = tableau.basis.clone();
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
return outcome;
return (outcome, None);
}
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
let is_art = |column: usize| column >= ny + n_slack && column < width;
@@ -423,8 +462,8 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
}
let allowed: Vec<bool> = (0..width).map(|j| !is_art(j)).collect();
match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded => return Outcome::Unbounded,
Step::Limit => return (Outcome::Limit, None),
Step::Unbounded => return (Outcome::Unbounded, None),
Step::Optimal => {}
}
let mut y = vec![0.0; width];
@@ -442,7 +481,43 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
}
}
let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum();
Outcome::Solved(x, val, true)
let tableau = FinalTableau { t, basis, obj, width, first_art: ny + n_slack, cols, ident, flipped };
(Outcome::Solved(x, val, true), Some(tableau))
}
/// The LP to minimise for `goal`, from the linearised model: the costs, and the rows
/// (coefficients, sense, right side) in the order of `model_rows`.
pub(super) fn linear_program(goal: Goal, cf: &[f64], f_const: f64, lin: &[(Vec<f64>, f64)], model_rows: &[Row]) -> (Vec<f64>, Vec<LpRow>) {
let mut rows: Vec<LpRow> = lin.iter().zip(model_rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
let c = match goal {
Goal::Max => cf.iter().map(|v| -v).collect(),
Goal::Min => cf.to_vec(),
Goal::Value(v) => {
rows.push((cf.to_vec(), Sense::Eq, v - f_const));
vec![0.0; cf.len()]
}
};
(c, rows)
}
/// The linear program Simplex LP solves: the model linearised at `x0`, its objective
/// coefficients, the costs and rows for the goal, and the work limit for its size.
pub(super) struct SimplexProgram {
pub(super) cf: Vec<f64>,
pub(super) c: Vec<f64>,
pub(super) rows: Vec<LpRow>,
pub(super) work: u64,
}
/// Builds the [`SimplexProgram`] at `x0`. The solve and the Sensitivity report both build it
/// here from the start values, so the report's solve repeats the solve's pivots and returns the
/// same vertex: linearising at another point changes the coefficients in their last bits, and
/// that can break a tie between pivots the other way.
pub(super) fn simplex_program(ev: &mut Evaluator, goal: Goal, x0: &[f64]) -> std::result::Result<SimplexProgram, &'static str> {
let ((cf, f_const), lin) = linearize(ev, x0)?;
let (c, rows) = linear_program(goal, &cf, f_const, &lin, &ev.rows);
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
Ok(SimplexProgram { cf, c, rows, work: LP_WORK.max(size) })
}
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
+26
View File
@@ -306,6 +306,32 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap })
}
/// Whether a `data.solver` result status is a solution the Sensitivity report can describe: an
/// optimum (Simplex LP) or a converged local optimum (GRG Nonlinear). A run that stopped at its
/// evaluation limit, or an Evolutionary run, has none.
pub fn sensitivity_status(status: &str) -> bool {
matches!(status, "optimal" | "converged")
}
/// Whether `data.solver` params (such as the `model` of its result) describe a model that can
/// have a Sensitivity report: Simplex LP or GRG Nonlinear, a Max or Min objective and no
/// integer or binary constraints.
pub fn sensitivity_applies(p: &Json) -> bool {
let method = str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "");
let goal = str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase();
let integer = p.get("constraints").and_then(Json::as_array).is_some_and(|list| {
list.iter().any(|c| {
let rel = match c {
Json::String(t) => split_constraint(t).map(|(_, rel, _)| rel),
Json::Object(_) => str_param(c, "op").or_else(|| str_param(c, "relation")).and_then(Rel::parse),
_ => None,
};
matches!(rel, Some(Rel::Int | Rel::Bin))
})
});
!integer && !matches!(method.as_str(), "evolutionary" | "evo" | "genetic") && !matches!(goal.as_str(), "value" | "valueof" | "value of")
}
// ---------------------------------------------------------------- saved model
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
+88 -20
View File
@@ -1,9 +1,10 @@
use super::lp::Outcome;
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
use super::problem::{Evaluator, Problem, as_num, build_problem};
use super::sensitivity::{sensitivity_refusal, sensitivity_report};
use super::{
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, edit, json, optimise,
str_param,
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, bool_param, edit, json,
optimise, str_param,
};
// ---------------------------------------------------------------- reports
@@ -11,6 +12,7 @@ use super::{
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum ReportKind {
Answer,
Sensitivity,
Limits,
}
@@ -18,6 +20,7 @@ impl ReportKind {
fn title(self) -> &'static str {
match self {
ReportKind::Answer => "Answer",
ReportKind::Sensitivity => "Sensitivity",
ReportKind::Limits => "Limits",
}
}
@@ -26,13 +29,14 @@ impl ReportKind {
/// The `reports` list (`None` when absent).
pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<ReportKind>>> {
let Some(list) = p.get("reports") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?;
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer, sensitivity and limits"))?;
let mut kinds = Vec::new();
for k in list {
let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() {
Some("answer") => ReportKind::Answer,
Some("sensitivity") => ReportKind::Sensitivity,
Some("limits") => ReportKind::Limits,
_ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))),
_ => return Err(bad(cmd, format!("unknown report {k} (answer, sensitivity or limits)"))),
};
if !kinds.contains(&kind) {
kinds.push(kind);
@@ -42,14 +46,14 @@ pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<Rep
}
/// A report's cells: (row, column, value, bold).
type Lines = Vec<(u32, u32, Value, bool)>;
pub(super) type Lines = Vec<(u32, u32, Value, bool)>;
fn abs_ref(c: CellRef) -> String {
pub(super) fn abs_ref(c: CellRef) -> String {
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
}
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
fn cell_name(sh: &Sheet, c: CellRef) -> String {
pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String {
let text = |c: CellRef| match sh.value(c) {
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
_ => None,
@@ -68,7 +72,7 @@ fn method_name(m: Method) -> &'static str {
}
/// Header labels in bold from column 1, then the rows below them; returns the next free row.
fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
pub(super) fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
for (k, h) in headers.iter().enumerate() {
out.push((row, 1 + k as u32, Value::text(*h), true));
}
@@ -92,7 +96,7 @@ pub(super) struct ReportInput<'a> {
pub(super) budget: u64,
}
fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
pub(super) fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true));
out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false));
3
@@ -255,18 +259,67 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
}
/// Builds every report before the solution or saved model can change.
pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
/// What happens to a Sensitivity report the model allows but the solution doesn't: `Refuse`
/// fails the call before anything changes; `Skip` leaves the report out, with its reason, and
/// writes the others. `optimum` is false when the solve stopped before an optimum, so the
/// report isn't attempted.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum NotOptimal {
Refuse,
Skip { optimum: bool },
}
/// The reports left out under [`NotOptimal::Skip`], as `{report, reason}`.
pub(super) type Skipped = Vec<Json>;
pub(super) fn prepare_reports(
d: &DocState,
kinds: &[ReportKind],
inp: &ReportInput,
not_optimal: NotOptimal,
) -> Result<(Vec<(String, Lines)>, Skipped)> {
if kinds.is_empty() {
return Ok(Vec::new());
return Ok((Vec::new(), Vec::new()));
}
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
// A request that must write a sheet is refused before any report is built (a Limits report
// runs two optimisations per variable); only a Sensitivity report that may be skipped waits
// for the check after the loop.
let must_write = kinds.iter().any(|k| *k != ReportKind::Sensitivity || not_optimal == NotOptimal::Refuse);
if must_write && d.wb.protected_structure {
return Err(protected());
}
// A model that has no Sensitivity report (integer, Evolutionary, Value Of) is refused in
// both modes before any report is built: a Limits report listed first would otherwise run
// its two optimisations per variable for an error. Only the solution's own refusal (not
// optimal) waits for the loop, where `Skip` may leave the report out.
if kinds.contains(&ReportKind::Sensitivity)
&& let Some(why) = sensitivity_refusal(inp.model, inp.prob)
{
return Err(EngineError::Other(why.into()));
}
let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?;
let mut built = Vec::new();
let mut skipped = Vec::new();
for k in kinds {
let lines = match k {
ReportKind::Answer => answer_report(d, sh, inp),
ReportKind::Sensitivity => match not_optimal {
NotOptimal::Skip { optimum } => {
let got = if optimum {
sensitivity_report(d, sh, inp).map_err(|e| e.to_string())
} else {
Err("Solver did not reach an optimal solution, so the Sensitivity report was not written.".to_string())
};
match got {
Ok(lines) => lines,
Err(reason) => {
skipped.push(json!({"report": "sensitivity", "reason": reason}));
continue;
}
}
}
NotOptimal::Refuse => sensitivity_report(d, sh, inp)?,
},
ReportKind::Limits => limits_report(d, sh, inp)?,
};
let name = (1..=10_000u32)
@@ -275,7 +328,16 @@ pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportIn
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
built.push((name, lines));
}
Ok(built)
// Only a report sheet changes the workbook's structure, so a request whose reports were all
// skipped still succeeds in a protected workbook.
if !built.is_empty() && d.wb.protected_structure {
return Err(protected());
}
Ok((built, skipped))
}
fn protected() -> EngineError {
EngineError::Other("The workbook is protected and cannot be changed.".into())
}
/// Inserts prepared reports before the model's sheet, which stays active.
@@ -292,7 +354,7 @@ pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style, py: None });
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 155.0), (6, 140.0), (7, 140.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
@@ -321,9 +383,9 @@ pub(super) fn insert_report_selections(s: &mut Session, at: usize, count: usize)
/// `data.solverReport`: reports for a solution of the sheet's saved model.
pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?;
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, sensitivity, limits)"))?;
if kinds.is_empty() {
return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)"));
return Err(bad(REPORT_CMD, "`reports` is empty (answer, sensitivity, limits)"));
}
let d = s.doc()?;
// The given model as is, else the saved model with any params given here over it.
@@ -365,8 +427,14 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
let budget = model.max_evals;
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
let not_optimal = if bool_param(p, "skipNotOptimal").unwrap_or(false) { NotOptimal::Skip { optimum: true } } else { NotOptimal::Refuse };
let (built, skipped) = prepare_reports(s.doc()?, &kinds, &inp, not_optimal)?;
// Nothing left to write (every report skipped): no edit, so no Undo step.
let sheets = if built.is_empty() { Vec::new() } else { edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))? };
insert_report_selections(s, model.sheet, sheets.len());
Ok(json!({"sheets": sheets}))
let mut out = json!({"sheets": sheets});
if !skipped.is_empty() {
out["skippedReports"] = json!(skipped);
}
Ok(out)
}
+614
View File
@@ -0,0 +1,614 @@
//! The Sensitivity report. Simplex LP: each variable's reduced cost and the range of its
//! objective coefficient over which the basis stays optimal, and each constraint's shadow price
//! and the range of its right side over which the shadow price holds, from the final tableau of
//! the LP linearised at the solution. GRG Nonlinear: the reduced gradients and the Lagrange
//! multipliers of the binding constraints at the solution, from difference gradients.
use super::lp::{FinalTableau, LpRow, Outcome, Row, Sense, SimplexProgram, simplex_program, solve_lp_tableau};
use super::model::{Goal, Method, Model};
use super::problem::{Evaluator, Point, Problem, as_num};
use super::reports::{Lines, ReportInput, ReportKind, abs_ref, cell_name, table, title};
use super::{CellRef, DocState, EPS, EngineError, Result, Sheet, Trial, Value};
/// Excel prints an unlimited allowable change as 1E+30.
const NO_LIMIT: f64 = 1e30;
/// Most arithmetic the Lagrange multipliers of a nonlinear model may take (about a second): a
/// pass over k binding rows and f free variables costs about 3·k²·f (the normal equations and
/// each multiplier's noise), and each pass drops at most one row, so a model at the constraint
/// limit could otherwise take minutes.
#[cfg(not(test))]
const MULTIPLIER_WORK: u64 = 1_000_000_000;
/// A low limit in tests, so the refusal is reachable with a small model.
#[cfg(test)]
const MULTIPLIER_WORK: u64 = 20_000;
/// Why a model has no Sensitivity report (`None` when it has one).
pub(super) fn sensitivity_refusal(m: &Model, prob: &Problem) -> Option<&'static str> {
if prob.integer.iter().any(|b| *b) {
Some("The Sensitivity report doesn't apply to models with integer or binary constraints.")
} else if m.method == Method::Evolutionary {
Some("The Sensitivity report is available for Simplex LP and GRG Nonlinear.")
} else if matches!(m.goal, Goal::Value(_)) {
Some("The Sensitivity report needs an objective to maximise or minimise.")
} else {
None
}
}
/// A sensitivity figure: values within `noise` of zero (rounding) print as 0, unlimited ones
/// as 1E+30. The noise level is local to the figure: it comes from the terms the figure is a
/// sum of (the rounding error of a sum scales with the sum of its terms' sizes), never from
/// the model's largest coefficient or gradient component, so a small price or gradient keeps
/// its value beside a large unrelated one, and never from the objective's value, so adding a
/// constant to the objective changes nothing the report shows.
fn sens_num(v: f64, noise: f64) -> Value {
if !v.is_finite() || v >= NO_LIMIT {
Value::number(NO_LIMIT)
} else if v.abs() <= noise {
Value::number(0.0)
} else {
Value::number(v)
}
}
fn num(v: f64) -> Value {
if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }
}
/// The smallest ratio `num / den` over the `den` above zero (the step to the first bound).
fn min_ratio(pairs: impl Iterator<Item = (f64, f64)>) -> f64 {
pairs.filter(|(_, den)| *den > EPS).map(|(num, den)| num.max(0.0) / den).fold(f64::INFINITY, f64::min)
}
/// Solves the square system `a·x = b` by Gaussian elimination with partial pivoting; unknowns
/// of a singular system that have no pivot are 0.
fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
let n = b.len();
let scale = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(f64::MIN_POSITIVE);
let mut pivots: Vec<Option<usize>> = vec![None; n];
let mut row = 0;
for col in 0..n {
let Some(p) = (row..n).max_by(|i, k| {
let v = |r: &usize| a.get(*r).and_then(|x| x.get(col)).map_or(0.0, |v| v.abs());
v(i).total_cmp(&v(k))
}) else {
break;
};
if a.get(p).and_then(|x| x.get(col)).is_none_or(|v| v.abs() <= 1e-12 * scale) {
continue;
}
a.swap(row, p);
b.swap(row, p);
let (Some(prow), Some(pb)) = (a.get(row).cloned(), b.get(row).copied()) else { break };
let pv = prow.get(col).copied().unwrap_or(1.0);
for (i, (r, bi)) in a.iter_mut().zip(b.iter_mut()).enumerate() {
if i == row {
continue;
}
let f = r.get(col).copied().unwrap_or(0.0) / pv;
if f != 0.0 {
for (v, p) in r.iter_mut().zip(&prow) {
*v -= f * p;
}
*bi -= f * pb;
}
}
if let Some(e) = pivots.get_mut(col) {
*e = Some(row);
}
row += 1;
}
pivots
.iter()
.enumerate()
.map(|(col, p)| match p {
Some(r) => b.get(*r).copied().unwrap_or(0.0) / a.get(*r).and_then(|x| x.get(col)).copied().unwrap_or(1.0),
None => 0.0,
})
.collect()
}
fn out_of_evaluations() -> EngineError {
EngineError::Other("Solver ran out of evaluations.".into())
}
fn not_optimal() -> EngineError {
EngineError::Other("The Sensitivity report needs an optimal solution: these values are not optimal for the model. Solve it first.".into())
}
/// What both report bodies share: the model, the solution, its objective value and the rows'
/// final values and right sides.
struct At<'a> {
inp: &'a ReportInput<'a>,
sh: &'a Sheet,
point: Point,
finals: Vec<(f64, f64)>,
}
impl At<'_> {
fn var_head(&self, j: usize, c: CellRef) -> Vec<Value> {
vec![Value::text(abs_ref(c)), Value::text(cell_name(self.sh, c)), num(self.inp.solution.get(j).copied().unwrap_or(f64::NAN))]
}
fn row_head(&self, i: usize, r: &Row) -> Vec<Value> {
let fin = self.finals.get(i).map_or(f64::NAN, |f| f.0);
vec![Value::text(abs_ref(r.lhs)), Value::text(cell_name(self.sh, r.lhs)), num(fin)]
}
}
/// The variable rows, the constraint rows and their headers.
type Body = (Vec<Vec<Value>>, Vec<Vec<Value>>, Vec<&'static str>, Vec<&'static str>);
pub(super) fn sensitivity_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
let m = inp.model;
if let Some(why) = sensitivity_refusal(m, inp.prob) {
return Err(EngineError::Other(why.into()));
}
let n = m.vars.len();
let x = &inp.solution;
let mut ev = Evaluator {
trial: Trial::new(d),
sheet: m.sheet,
vars: m.vars.clone(),
objective: m.objective,
rows: inp.prob.rows.clone(),
evals: 0,
// Room for the gradients (GRG) or two linearisations (Simplex LP) at the least.
budget: inp.budget.max(3 * n as u64 + 4),
target: None,
};
let point = ev.eval(x).ok_or_else(out_of_evaluations)?;
// The rows' final values and right sides at the solution.
let mut cells = Vec::new();
for r in &inp.prob.rows {
cells.push(r.lhs);
cells.extend(r.rhs);
}
let mut trial = Trial::new(d);
let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect();
let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect();
let mut vals = trial.run(&sets, &reads).into_iter().map(|v| as_num(&v));
let finals: Vec<(f64, f64)> = inp
.prob
.rows
.iter()
.map(|r| {
let l = vals.next().unwrap_or(f64::NAN);
(l, if r.rhs.is_some() { vals.next().unwrap_or(f64::NAN) } else { r.rhs_const })
})
.collect();
let at = At { inp, sh, point, finals };
let (var_rows, con_rows, var_headers, con_headers) =
if m.method == Method::Simplex { simplex_body(&mut ev, &at)? } else { nonlinear_body(&mut ev, &at)? };
let mut out = Lines::new();
let r = title(&mut out, ReportKind::Sensitivity, &sh.name);
out.push((r, 0, Value::text("Variable Cells"), true));
let r = table(&mut out, r + 1, &var_headers, var_rows);
out.push((r, 0, Value::text("Constraints"), true));
table(&mut out, r + 1, &con_headers, con_rows);
Ok(out)
}
/// Simplex LP: reduced costs, shadow prices and their ranges from the final tableau.
fn simplex_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
let (inp, m) = (at.inp, at.inp.model);
// The program the solve built, from the same start values (data.solver's x0, which Solver
// Results passes back as `original`), so the solve below repeats its pivots. Start values
// the model can't be evaluated at (given only to the report) fall back to the solution.
let start: Vec<f64> = inp.original.iter().map(|v| if v.is_finite() { *v } else { 0.0 }).collect();
let built = match simplex_program(ev, m.goal, &start) {
Err("nonlinear" | "error") if start != inp.solution => simplex_program(ev, m.goal, &inp.solution),
other => other,
};
let SimplexProgram { cf, c, rows, mut work } = built.map_err(|e| {
if e == "budget" {
out_of_evaluations()
} else {
EngineError::Other("The model isn't linear in the variable cells, so Simplex LP can't report its sensitivity.".into())
}
})?;
let (outcome, tab) = solve_lp_tableau(&c, &rows, &inp.prob.lo, &inp.prob.hi, &mut work);
let (Outcome::Solved(vertex, best, _), Some(tab)) = (outcome, tab) else {
return Err(EngineError::Other("Solver could not find an optimal basis for the Sensitivity report.".into()));
};
// The report describes the optimum, so the values it shows must be one.
if !optimal_point(&c, &rows, (&inp.prob.lo, &inp.prob.hi), &inp.solution, best) {
return Err(not_optimal());
}
// The prices and ranges come from the tableau of `vertex`, so the values shown must be that
// vertex. Another optimum with the same objective (when the optimum is not unique) has other
// ranges, so it is refused too, although it is optimal.
let near = |a: f64, b: f64| (a - b).abs() <= super::PRECISION * (1.0 + a.abs().max(b.abs()));
if vertex.len() != inp.solution.len() || !vertex.iter().zip(&inp.solution).all(|(a, b)| near(*a, *b)) {
return Err(EngineError::Other(
"The Sensitivity report needs the values at the Simplex LP solution: these values are another optimal solution, whose ranges differ. Solve the model with Simplex LP first."
.into(),
));
}
// Minimising `sign · objective`.
let sign = if m.goal == Goal::Max { -1.0 } else { 1.0 };
// A shadow price is the dual yᵢ = Σᵣ c_B(r)·B⁻¹(r, i): the row's unit column of the final
// tableau holds B⁻¹'s column i, and c_B(r) is the cost of the column basic in row r (a
// slack or artificial costs nothing). The rounding error of a sum scales with the sum of
// its terms' sizes, so a price below 1E-9 of Σᵣ |c_B(r)·B⁻¹(r, i)| is noise. The bound is
// the row's own: a small price keeps its value beside a large coefficient that plays no
// part in it, and a constant in the objective (no coefficient) changes no bound. Allowable
// changes to a coefficient or a right side are ratios the tableau's own pivot tolerance
// (`EPS`) already bounds.
let cost_size = |b: usize| tab.cols.get(b).map_or(0.0, |mp| cf.get(mp.var).copied().unwrap_or(0.0).abs());
let price_terms = |col: usize| (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| cost_size(*b)) * entry(&tab, r, col).abs()).sum::<f64>();
let (shadow, terms): (Vec<f64>, Vec<f64>) = (0..rows.len())
.map(|i| {
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
(sign * flip(&tab, i) * -tab.obj.get(col).copied().unwrap_or(0.0), price_terms(col))
})
.unzip();
let noise = |terms: f64| 1e-9 * terms.max(f64::MIN_POSITIVE);
let con_rows = ev
.rows
.iter()
.enumerate()
.map(|(i, r)| {
// The basic values move along the row's unit column as its right side changes.
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
let dir: Vec<(f64, f64)> =
(0..tab.t.len()).filter(|r| real_basic(&tab, *r)).map(|r| (entry(&tab, r, tab.width), flip(&tab, i) * entry(&tab, r, col))).collect();
// An artificial still basic after phase one stands for a row the others make
// redundant, and it is zero there. When this right side moves it, any change alone
// leaves no solution, so both ranges are 0.
let pinned = (0..tab.t.len()).any(|r| !real_basic(&tab, r) && entry(&tab, r, col).abs() > EPS);
let (increase, decrease) =
if pinned { (0.0, 0.0) } else { (min_ratio(dir.iter().map(|(v, d)| (*v, -d))), min_ratio(dir.iter().map(|(v, d)| (*v, *d)))) };
let b = at.finals.get(i).map_or(f64::NAN, |f| f.1);
let mut row = at.row_head(i, r);
let price = sens_num(shadow.get(i).copied().unwrap_or(0.0), noise(terms.get(i).copied().unwrap_or(0.0)));
row.extend([price, num(b), sens_num(increase, EPS), sens_num(decrease, EPS)]);
row
})
.collect();
let nonbasic: Vec<usize> = (0..tab.first_art).filter(|l| !tab.basis.contains(l)).collect();
let var_rows = m
.vars
.iter()
.enumerate()
.map(|(j, c)| {
let coef = cf.get(j).copied().unwrap_or(0.0);
let used: f64 = rows.iter().zip(&shadow).map(|((a, _, _), p)| a.get(j).copied().unwrap_or(0.0) * p).sum();
// The reduced cost cf_j − Σᵢ a_ij·yᵢ is a sum of cf_j and the prices' terms times
// a_ij: the prices' terms rather than the prices, since a price that is a small
// difference of large terms carries their rounding error into every reduced cost
// it is used in.
let used_terms: f64 = rows.iter().zip(&terms).map(|((a, _, _), t)| a.get(j).copied().unwrap_or(0.0).abs() * t).sum();
// A change Δ in the coefficient changes each nonbasic reduced cost by Δ·e.
let own = |col: usize| tab.cols.get(col).filter(|mp| mp.var == j).map_or(0.0, |mp| mp.sign);
let moves: Vec<(f64, f64)> = nonbasic
.iter()
.map(|&l| {
let through_basis: f64 = (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| own(*b)) * entry(&tab, r, l)).sum();
(tab.obj.get(l).copied().unwrap_or(0.0), sign * (own(l) - through_basis))
})
.collect();
let increase = min_ratio(moves.iter().map(|(d, e)| (*d, -e)));
let decrease = min_ratio(moves.iter().map(|(d, e)| (*d, *e)));
let mut row = at.var_head(j, *c);
row.extend([sens_num(coef - used, noise(coef.abs() + used_terms)), num(coef), sens_num(increase, EPS), sens_num(decrease, EPS)]);
row
})
.collect();
Ok((
var_rows,
con_rows,
vec!["Cell", "Name", "Final Value", "Reduced Cost", "Objective Coefficient", "Allowable Increase", "Allowable Decrease"],
vec!["Cell", "Name", "Final Value", "Shadow Price", "Constraint R.H. Side", "Allowable Increase", "Allowable Decrease"],
))
}
/// Whether `x` satisfies the LP (rows and bounds) and reaches its optimum `best` (minimising
/// `c·x`), within the constraint precision.
fn optimal_point(c: &[f64], rows: &[LpRow], (lo, hi): (&[f64], &[f64]), x: &[f64], best: f64) -> bool {
let near = |v: f64, size: f64| v <= super::PRECISION * (1.0 + size);
let dot = |a: &[f64]| a.iter().zip(x).fold((0.0, 0.0), |(s, m), (p, q)| (s + p * q, m + (p * q).abs()));
let rows_hold = rows.iter().all(|(a, sense, b)| {
let (lhs, size) = dot(a);
let size = size + b.abs();
match sense {
Sense::Le => near(lhs - b, size),
Sense::Ge => near(b - lhs, size),
Sense::Eq => near((lhs - b).abs(), size),
}
});
let bounds_hold = x.iter().enumerate().all(|(j, v)| {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
v.is_finite() && near(l - v, v.abs()) && near(v - h, v.abs())
});
let (value, size) = dot(c);
// Two-sided: a point just outside a bound can beat the optimum by far more than the
// precision when its costs are large.
rows_hold && bounds_hold && near((value - best).abs(), size + best.abs())
}
fn entry(tab: &FinalTableau, r: usize, col: usize) -> f64 {
tab.t.get(r).and_then(|row| row.get(col)).copied().unwrap_or(0.0)
}
fn real_basic(tab: &FinalTableau, r: usize) -> bool {
tab.basis.get(r).is_some_and(|b| *b < tab.first_art)
}
/// -1 for a row the tableau negated, else 1.
fn flip(tab: &FinalTableau, i: usize) -> f64 {
if tab.flipped.get(i).copied().unwrap_or(false) { -1.0 } else { 1.0 }
}
/// Central-difference gradients, inside the bounds, of the objective and of each row's
/// `lhs − rhs` at the solution.
fn gradients(ev: &mut Evaluator, inp: &ReportInput) -> Result<(Vec<f64>, Vec<Vec<f64>>)> {
let x = &inp.solution;
let n = x.len();
let mut df = vec![0.0; n];
let mut dg = vec![vec![0.0; n]; ev.rows.len()];
for j in 0..n {
let xj = x.get(j).copied().unwrap_or(0.0);
let (l, u) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
let h = 1e-6 * xj.abs().max(1.0);
let (a, b) = ((xj - h).max(l), (xj + h).min(u));
if b <= a {
continue;
}
let mut point = |v: f64| {
let mut p = x.clone();
if let Some(e) = p.get_mut(j) {
*e = v;
}
ev.eval(&p).ok_or_else(out_of_evaluations)
};
let (pa, pb) = (point(a)?, point(b)?);
if let Some(e) = df.get_mut(j) {
*e = (pb.f - pa.f) / (b - a);
}
for (row, (ga, gb)) in dg.iter_mut().zip(pa.g.iter().zip(&pb.g)) {
if let Some(e) = row.get_mut(j) {
*e = (gb - ga) / (b - a);
}
}
}
Ok((df, dg))
}
/// The rounding noise in the Lagrange multiplier of row `i`, one of the `active` rows whose
/// multipliers `lambda` solve the normal equations of ∇f = Σ λₖ ∇gₖ over the `free` variables.
/// Row i's equation gives λᵢ = (Σⱼ gᵢⱼ·dfⱼ − Σ_{k≠i} λₖ·Σⱼ gᵢⱼ·gₖⱼ) / Σⱼ gᵢⱼ²; the difference
/// gradients are good to about 1E-6 of each component, and the rounding error of a sum scales
/// with the sum of its terms' sizes, so the noise is 1E-6 of the terms' sizes over the row's
/// own squared norm. The bound is local to the row: another row enters only through its
/// coupling term with this one, so a row whose gradient is orthogonal to the others has a bound
/// from its own gradient alone, and a large component of ∇f that the row's gradient doesn't
/// touch hides nothing. Nearly parallel active gradients make the normal equations
/// ill-conditioned and amplify every multiplier's error beyond any bound local to a row; such
/// multipliers are ill-determined whatever the bound, and they print as computed.
fn multiplier_noise(i: usize, active: &[usize], lambda: &[f64], free: &[usize], (df, dg): (&[f64], &[Vec<f64>])) -> f64 {
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let own: f64 = free.iter().map(|&j| grad(i, j) * grad(i, j)).sum();
let objective: f64 = free.iter().map(|&j| (grad(i, j) * df.get(j).copied().unwrap_or(0.0)).abs()).sum();
let coupling: f64 = active
.iter()
.zip(lambda)
.filter(|(k, _)| **k != i)
.map(|(&k, l)| free.iter().map(|&j| (grad(i, j) * grad(k, j) * l).abs()).sum::<f64>())
.sum();
1e-6 * (objective + coupling) / own.max(f64::MIN_POSITIVE)
}
/// Least squares on the free variables: ∇f = Σ λᵢ ∇gᵢ over the `active` rows. An inequality's
/// multiplier has the sign that tightening it costs (λ ≥ 0 for `lhs ≤ rhs` when maximising); a
/// row whose multiplier has the other sign beyond its own noise ([`multiplier_noise`], the
/// criterion the report rounds it with) isn't binding, so it leaves and the rest are solved
/// again; the objective's value plays no part. Returns each row's multiplier and its noise (0
/// and 0 for a row that doesn't bind). The work is bounded by `budget` (see
/// [`MULTIPLIER_WORK`]); past it the report is refused.
fn multipliers(
rows: &[Row],
goal: Goal,
mut active: Vec<usize>,
free: &[usize],
(df, dg): (&[f64], &[Vec<f64>]),
budget: u64,
) -> Result<(Vec<f64>, Vec<f64>)> {
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let goal_sign = if goal == Goal::Max { 1.0 } else { -1.0 };
let mut lambda = vec![0.0; rows.len()];
let mut noise = vec![0.0; rows.len()];
let mut work = 0u64;
while !active.is_empty() {
let (k, f) = (active.len() as u64, (free.len() as u64).max(1));
work = work.saturating_add(3u64.saturating_mul(k).saturating_mul(k).saturating_mul(f));
if work > budget {
return Err(EngineError::Other(
"The Sensitivity report of this nonlinear model has too many binding constraints to work out in reasonable time.".into(),
));
}
let normal: Vec<Vec<f64>> =
active.iter().map(|&i| active.iter().map(|&k| free.iter().map(|&j| grad(i, j) * grad(k, j)).sum()).collect()).collect();
let rhs: Vec<f64> = active.iter().map(|&i| free.iter().map(|&j| grad(i, j) * df.get(j).copied().unwrap_or(0.0)).sum()).collect();
let solved = solve_dense(normal, rhs);
let bounds: Vec<f64> = active.iter().map(|&i| multiplier_noise(i, &active, &solved, free, (df, dg))).collect();
let wrong = active
.iter()
.zip(&solved)
.zip(&bounds)
.enumerate()
.filter_map(|(k, ((&i, l), bound))| {
let want = match rows.get(i).map_or(Sense::Eq, |r| r.sense) {
Sense::Le => goal_sign,
Sense::Ge => -goal_sign,
Sense::Eq => return None,
};
(want * l < -bound).then_some((k, want * l))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match wrong {
Some((k, _)) => {
active.remove(k);
}
None => {
for ((i, l), bound) in active.iter().zip(solved).zip(bounds) {
if let (Some(e), Some(n)) = (lambda.get_mut(*i), noise.get_mut(*i)) {
*e = l;
*n = bound;
}
}
break;
}
}
}
Ok((lambda, noise))
}
/// Whether the solution is a Karush-Kuhn-Tucker point of the model, from the gradients the
/// report computed: every row and bound holds, and each reduced gradient (the objective's
/// gradient less what the binding rows' multipliers explain) is near zero relative to `terms`,
/// the sizes of the terms it is made of, or, for a variable at a bound, points only out of that
/// bound in the objective's direction. A binding row whose multiplier had the wrong sign was
/// dropped by [`multipliers`], so the part of the gradient it would have explained remains in
/// the reduced gradients and the point is refused here. Every tolerance is local to the row or
/// variable it tests: a large right side or gradient component elsewhere in the model excuses
/// nothing here.
fn kkt_point(at: &At, rows: &[Row], terms: &[f64], reduced: &[f64]) -> bool {
let (inp, x) = (at.inp, &at.inp.solution);
// Feasibility: each row within ten times the constraint precision of its own right side.
// The nonlinear method accepts a solution within that of the model's largest constant right
// side, which lets a small row go by its own size; a row over by more than its own
// precision isn't satisfied, whatever the other rows' sizes, so the report refuses it.
let rows_hold = rows.iter().enumerate().all(|(i, r)| {
let (g, b) = (at.point.g.get(i).copied().unwrap_or(f64::NAN), at.finals.get(i).map_or(f64::NAN, |f| f.1));
let over = match r.sense {
Sense::Le => g,
Sense::Ge => -g,
Sense::Eq => g.abs(),
};
over <= 10.0 * super::PRECISION * (1.0 + b.abs())
});
if !rows_hold || !at.point.f.is_finite() {
return false;
}
// Stationarity: each reduced gradient relative to the terms it is made of (the objective's
// component and the multipliers' parts, at least 1). At optima the nonlinear method solves,
// a reduced gradient is below about 1E-6 of those terms; 1E-3 leaves a wide margin for the
// method's stopping test and for rounding in the difference gradients of badly scaled
// models, and still refuses a point whose objective improves visibly along a direction the
// constraints allow. The bound is the variable's own: with a bound from the gradient's
// largest component, max 1E9·x + y at (1, 0.5) passed, although raising y gains 1 per unit
// against a tolerance of 1E6.
// Moving up a variable improves a maximum by +r and a minimum by −r per unit.
let sign = if inp.model.goal == Goal::Max { 1.0 } else { -1.0 };
x.iter().enumerate().all(|(j, &v)| {
let (l, h) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
let near = super::PRECISION * (1.0 + v.abs());
if !v.is_finite() || v < l - near || v > h + near {
return false;
}
// The same test for "at a bound" as the choice of free variables above.
let edge = 1e-6 * v.abs().max(1.0);
let (at_lo, at_hi) = (v <= l + edge, v >= h - edge);
let gain = reduced.get(j).map_or(f64::NAN, |r| sign * r);
let tol = 1e-3 * terms.get(j).copied().unwrap_or(0.0).max(1.0);
gain.is_finite() && (at_hi || gain <= tol) && (at_lo || gain >= -tol)
})
}
/// GRG Nonlinear: reduced gradients and the binding rows' Lagrange multipliers.
fn nonlinear_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
let (inp, m) = (at.inp, at.inp.model);
let x = &inp.solution;
let (df, dg) = gradients(ev, inp)?;
// Binding rows (within the nonlinear method's accuracy) and the variables strictly inside
// their bounds.
let active: Vec<usize> = ev
.rows
.iter()
.enumerate()
.filter(|(i, r)| {
let (g, b) = (at.point.g.get(*i).copied().unwrap_or(f64::NAN), at.finals.get(*i).map_or(0.0, |f| f.1));
r.sense == Sense::Eq || g.abs() <= 1e-4 * (1.0 + b.abs())
})
.map(|(i, _)| i)
.collect();
let free: Vec<usize> = (0..x.len())
.filter(|&j| {
let v = x.get(j).copied().unwrap_or(0.0);
let tol = 1e-6 * v.abs().max(1.0);
v > inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY) + tol && v < inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY) - tol
})
.collect();
let (lambda, lambda_noise) = multipliers(&ev.rows, m.goal, active, &free, (&df, &dg), MULTIPLIER_WORK)?;
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let reduced: Vec<f64> =
(0..x.len()).map(|j| df.get(j).copied().unwrap_or(0.0) - lambda.iter().enumerate().map(|(i, l)| l * grad(i, j)).sum::<f64>()).collect();
// A reduced gradient dfⱼ − Σᵢ λᵢ·gᵢⱼ is a sum of the gradient's own component and the
// multipliers' terms; the sizes of those terms bound its rounding error and scale the test
// of whether it is zero.
let terms: Vec<f64> = (0..x.len())
.map(|j| df.get(j).copied().unwrap_or(0.0).abs() + lambda.iter().enumerate().map(|(i, l)| (l * grad(i, j)).abs()).sum::<f64>())
.collect();
// The report describes the optimum, so the values it shows must be one.
if !kkt_point(at, &ev.rows, &terms, &reduced) {
return Err(not_optimal());
}
// Difference gradients are good to about 1E-6 of each component, so a reduced gradient's
// noise is 1E-6 of its terms' sizes, plus each multiplier's own noise
// ([`multiplier_noise`]) times |gᵢⱼ|. The bound is the variable's own: a small gradient
// keeps its value beside a large component of another variable.
let reduced_noise = |j: usize| {
let carried: f64 = lambda_noise.iter().enumerate().map(|(i, n)| if grad(i, j) == 0.0 { 0.0 } else { n * grad(i, j).abs() }).sum();
1e-6 * terms.get(j).copied().unwrap_or(0.0) + carried
};
let var_rows = m
.vars
.iter()
.enumerate()
.map(|(j, c)| {
let mut row = at.var_head(j, *c);
row.push(sens_num(reduced.get(j).copied().unwrap_or(f64::NAN), reduced_noise(j)));
row
})
.collect();
let con_rows = ev
.rows
.iter()
.enumerate()
.map(|(i, r)| {
let mut row = at.row_head(i, r);
row.push(sens_num(lambda.get(i).copied().unwrap_or(0.0), lambda_noise.get(i).copied().unwrap_or(0.0)));
row
})
.collect();
Ok((var_rows, con_rows, vec!["Cell", "Name", "Final Value", "Reduced Gradient"], vec!["Cell", "Name", "Final Value", "Lagrange Multiplier"]))
}
#[cfg(test)]
mod tests {
use super::{Goal, MULTIPLIER_WORK, Row, Sense, multipliers};
use gridcraft_core::CellRef;
#[test]
fn multipliers_at_the_constraint_limit_stop_at_the_work_limit() {
// 1,000 binding `<=` rows over 200 free variables, each multiplier with the wrong sign:
// without a limit every pass would drop one row and solve again, about 4·10⁸ operations
// a pass for up to 1,000 passes. The limit (lowered in tests) refuses it at once.
let (k, f) = (1000usize, 200usize);
let rows: Vec<Row> = (0..k).map(|_| Row { lhs: CellRef::new(0, 0), sense: Sense::Le, rhs: None, rhs_const: 0.0 }).collect();
let dg: Vec<Vec<f64>> = (0..k).map(|i| (0..f).map(|j| if j == i % f { 1.0 } else { 0.0 }).collect()).collect();
let df = vec![-1.0; f];
let free: Vec<usize> = (0..f).collect();
let got = multipliers(&rows, Goal::Max, (0..k).collect(), &free, (&df, &dg), MULTIPLIER_WORK);
assert!(got.is_err());
// A small model within the limit still gets its multipliers: ∇f = (1, 2) on two
// binding rows with unit gradients.
let dg2 = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let (got, noise) = multipliers(&rows[..2], Goal::Max, vec![0, 1], &free[..2], (&[1.0, 2.0], &dg2), MULTIPLIER_WORK).unwrap();
assert_eq!(got, vec![1.0, 2.0]);
// Each multiplier's noise is 1E-6 of its own row's term (1 · 1 and 1 · 2 over a unit
// norm): the rows are orthogonal, so neither enters the other's bound.
assert_eq!(noise, vec![1e-6, 2e-6]);
}
}
+819
View File
@@ -283,6 +283,8 @@ fn refused_solver_reports_preserve_the_document_and_history() {
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "sensitivity"]), "integer or binary"),
(false, "evolutionary", json!(["A1 <= 4"]), json!(["sensitivity"]), "Simplex LP and GRG"),
] {
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
let mut s = s();
@@ -400,6 +402,823 @@ fn solver_report_insertion_keeps_variable_edit_guards() {
}
}
/// The numbers from column D on in the row below the one holding `text` on report `name`.
fn report_row(s: &Session, name: &str, text: &str, cols: &[&str]) -> Vec<f64> {
let r = row_of(s, name, text) + 1;
cols.iter().map(|c| at(s, name, &format!("{c}{r}")).as_f64().unwrap()).collect()
}
fn close(got: &[f64], want: &[f64], tol: f64) -> bool {
got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol * (1.0 + w.abs()))
}
#[test]
fn sensitivity_report_gives_the_wyndor_glass_shadow_prices_and_ranges() {
// Hillier & Lieberman's Wyndor Glass sensitivity analysis: shadow prices 0, 1.5 and 1
// for the three plants; plant 1's hours may fall by 2 and rise without limit, plants 2's
// and 3's by 6 either way; the doors' profit 3 may range over 0..7.5, the windows' 5 over
// 2 and up.
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}),
)
.unwrap();
for r in 3..=6 {
set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]);
}
let solved = s
.execute(
"data.solver",
json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["sensitivity"]}),
)
.unwrap();
assert_eq!(solved["reports"], json!(["Sensitivity Report 1"]), "{solved}");
let rep = "Sensitivity Report 1";
assert_eq!(at(&s, rep, "A1"), Value::text("GridCraft Sensitivity Report"));
let r = row_of(&s, rep, "Reduced Cost");
assert_eq!(at(&s, rep, &format!("F{}", r + 1)), Value::text("Objective Coefficient"));
let cols = ["D", "E", "F", "G", "H"];
// Final value, reduced cost, objective coefficient, allowable increase and decrease.
assert!(close(&report_row(&s, rep, "$B$2", &cols)[1..], &[0.0, 3.0, 4.5, 3.0], 1e-9));
assert!(close(&report_row(&s, rep, "$C$2", &cols), &[6.0, 0.0, 5.0, 1e30, 3.0], 1e-9));
// Final value, shadow price, right side, allowable increase and decrease.
assert!(close(&report_row(&s, rep, "$D$3", &cols), &[2.0, 0.0, 4.0, 1e30, 2.0], 1e-9));
assert!(close(&report_row(&s, rep, "$D$4", &cols), &[12.0, 1.5, 12.0, 6.0, 6.0], 1e-9));
assert!(close(&report_row(&s, rep, "$D$5", &cols), &[18.0, 1.0, 18.0, 6.0, 6.0], 1e-9));
// A minimisation with `>=` rows: min 2x + 3y, x + y ≥ 4, x + 3y ≥ 6 → (3, 1), cost 9.
// The duals 1.5 and 0.5 solve u + v = 2, u + 3v = 3; the basis holds for 2 ≤ b₁ ≤ 6,
// 4 ≤ b₂ ≤ 12, 1 ≤ c₁ ≤ 3 and 2 ≤ c₂ ≤ 6.
set(&mut s, &[("H1", "0"), ("H2", "0"), ("I1", "=2*H1+3*H2"), ("I2", "=H1+H2"), ("I3", "=H1+3*H2")]);
let r = s
.execute(
"data.solver",
json!({"objective": "I1", "goal": "min", "variables": "H1:H2", "constraints": ["I2 >= 4", "I3 >= 6"], "method": "simplex", "reports": ["sensitivity"]}),
)
.unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$H$1", &cols), &[3.0, 0.0, 2.0, 1.0, 1.0], 1e-9), "{r}");
assert!(close(&report_row(&s, &rep, "$H$2", &cols), &[1.0, 0.0, 3.0, 3.0, 1.0], 1e-9));
assert!(close(&report_row(&s, &rep, "$I$2", &cols), &[4.0, 1.5, 4.0, 2.0, 2.0], 1e-9));
assert!(close(&report_row(&s, &rep, "$I$3", &cols), &[6.0, 0.5, 6.0, 6.0, 2.0], 1e-9));
// A variable held at zero has a reduced cost: min 2x + 3y with x + y ≥ 4 alone uses
// only x; y would add 3 − 2 = 1 per unit.
let r = s
.execute(
"data.solver",
json!({"objective": "I1", "goal": "min", "variables": "H1:H2", "constraints": ["I2 >= 4"], "method": "simplex", "reports": ["sensitivity"]}),
)
.unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$H$2", &cols), &[0.0, 1.0, 3.0, 1e30, 1.0], 1e-9), "{r}");
// Integer models, the Evolutionary method and Value Of goals have no Sensitivity report.
s.execute("data.solver", json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex"})).unwrap();
for p in [
json!({"reports": ["sensitivity"], "constraints": ["D3:D5 <= E3:E5", "B2:C2 int"]}),
json!({"reports": ["sensitivity"], "method": "evolutionary", "constraints": ["D3:D5 <= E3:E5", "B2:C2 <= 10"]}),
json!({"reports": ["sensitivity"], "goal": "value", "value": 30}),
] {
assert!(s.execute("data.solverReport", p.clone()).is_err(), "{p}");
}
// data.solver refuses a Value Of goal's Sensitivity report before it changes anything.
set(&mut s, &[("B2", "1"), ("C2", "1")]);
let undo = s.doc().unwrap().undo.len();
let p = json!({"objective": "D6", "goal": "value", "value": 30, "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["sensitivity"]});
let err = s.execute("data.solver", p).unwrap_err();
assert!(err.to_string().contains("maximise or minimise"), "{err}");
assert_eq!((at(&s, "Sheet1", "B2"), at(&s, "Sheet1", "C2")), (Value::number(1.0), Value::number(1.0)));
assert_eq!(s.doc().unwrap().undo.len(), undo);
}
#[test]
fn simplex_sensitivity_ranges_of_a_redundant_equality_are_zero() {
// max x with x = 2 and 2x = 4: the second row repeats the first, so any change to either
// right side alone has no solution, and both ranges of each row are 0. With x = 2 alone the
// right side may rise without limit and fall by 2 (to x = 0).
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=A1"), ("C1", "=A1"), ("C2", "=2*A1")]);
let cols = ["D", "F", "G", "H"];
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["C1 = 2", "C2 = 4"], "method": "simplex", "reports": ["sensitivity"]}),
)
.unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$C$1", &cols), &[2.0, 2.0, 0.0, 0.0], 1e-9), "{:?}", report_row(&s, &rep, "$C$1", &cols));
assert!(close(&report_row(&s, &rep, "$C$2", &cols), &[4.0, 4.0, 0.0, 0.0], 1e-9), "{:?}", report_row(&s, &rep, "$C$2", &cols));
// The redundant row doesn't restrict the objective coefficient: x = 2 stays the only
// solution whatever its profit.
assert!(close(&report_row(&s, &rep, "$A$1", &["D", "F", "G", "H"]), &[2.0, 1.0, 1e30, 1e30], 1e-9));
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["C1 = 2"], "method": "simplex", "reports": ["sensitivity"]}),
)
.unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$C$1", &cols), &[2.0, 2.0, 1e30, 2.0], 1e-9), "{:?}", report_row(&s, &rep, "$C$1", &cols));
}
#[test]
fn simplex_sensitivity_report_needs_an_optimal_point() {
// Wyndor Glass: (2, 6) is the optimum (36). (0, 0) is feasible but not optimal; (4, 4.8)
// reaches 36 but breaks plant 3's hours (21.6 > 18).
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}),
)
.unwrap();
for r in 3..=6 {
set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]);
}
let model = json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex"});
let report = |b: f64, c: f64| json!({"reports": ["sensitivity"], "model": model, "values": {"B2": b, "C2": c}});
for (b, c) in [(0.0, 0.0), (4.0, 4.8)] {
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", report(b, c)).unwrap_err();
assert!(err.to_string().contains("not optimal"), "({b}, {c}): {err}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
}
let r = s.execute("data.solverReport", report(2.0, 6.0)).unwrap();
assert_eq!(r["sheets"], json!(["Sensitivity Report 1"]), "{r}");
}
#[test]
fn simplex_sensitivity_report_describes_the_vertex_the_solver_returns() {
// max x + y with x + y <= 4, x <= 3, y <= 3: every point from (3, 1) to (1, 3) is optimal,
// but the ranges belong to one vertex. The vertex Simplex LP returns is reported (from
// several starting values, as Solver Results passes the solve's result on); the other vertex
// and the midpoint are refused.
let model = json!({"objective": "D1", "variables": "A1:A2", "constraints": ["C1 <= 4", "A1 <= 3", "A2 <= 3"], "method": "simplex"});
let mut found = Vec::new();
for (x, y) in [("0", "0"), ("0.1", "0.7"), ("2.9", "0.3"), ("1", "1"), ("-5", "12.25")] {
let mut s = s();
set(&mut s, &[("A1", x), ("A2", y), ("C1", "=A1+A2"), ("D1", "=A1+A2")]);
let mut p = model.clone();
p["keep"] = json!(false);
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": r["model"], "values": r["values"]})).unwrap();
assert_eq!(rep["sheets"], json!(["Sensitivity Report 1"]), "from ({x}, {y}): {rep}");
found.push((r["values"]["A1"].as_f64().unwrap(), r["values"]["A2"].as_f64().unwrap()));
}
let (vx, vy) = found[0];
assert!(found.iter().all(|v| *v == (vx, vy)), "{found:?}");
let other = if vx == 3.0 { (1.0, 3.0) } else { (3.0, 1.0) };
assert!([(3.0, 1.0), (1.0, 3.0)].contains(&(vx, vy)), "{found:?}");
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("C1", "=A1+A2"), ("D1", "=A1+A2")]);
for (x, y) in [other, (2.0, 2.0)] {
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": x, "A2": y}})).unwrap_err();
assert!(err.to_string().contains("another optimal solution"), "({x}, {y}): {err}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
// data.solver's Skip mode (as Solver Results passes) skips only the Sensitivity report.
let r = s
.execute(
"data.solverReport",
json!({"reports": ["answer", "sensitivity"], "model": model, "values": {"A1": x, "A2": y}, "skipNotOptimal": true}),
)
.unwrap();
assert_eq!(r["skippedReports"][0]["report"], "sensitivity", "{r}");
assert_eq!(r["sheets"].as_array().map(Vec::len), Some(1), "{r}");
}
let r = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": vx, "A2": vy}})).unwrap();
assert!(r["sheets"][0].as_str().unwrap().starts_with("Sensitivity Report"), "{r}");
}
#[test]
fn simplex_sensitivity_report_repeats_the_solve_from_its_start_values() {
// max 0.1x + 0.1y with 0.1x + 0.1y <= 0.4, 0.7x + 0.7y <= 2.8, x <= 3, y <= 3: the optimum is
// not unique, and the difference coefficients of these formulas differ in their last bits
// with the point they are taken at. From these starts the solve returns (3, 1) or (1, 3);
// linearised at that solution instead, the report's solve broke the tie the other way and
// refused the solve's own values. Built from the same start values, the program is the
// same to the bit, so each solve's own report is written.
let model =
json!({"objective": "D1", "variables": "A1:A2", "constraints": ["C1 <= 0.4", "E1 <= 2.8", "A1 <= 3", "A2 <= 3"], "method": "simplex"});
let formulas = [("C1", "=A1*0.1+A2*0.1"), ("D1", "=A1*0.1+A2*0.1"), ("E1", "=A1*0.7+A2*0.7")];
let program = |s: &Session, x: &[f64]| {
let m = super::parse_model(s, &model).unwrap();
let prob = super::build_problem(&m);
let mut ev = super::Evaluator {
trial: super::Trial::new(s.doc().unwrap()),
sheet: m.sheet,
vars: m.vars.clone(),
objective: m.objective,
rows: prob.rows.clone(),
evals: 0,
budget: 100,
target: None,
};
let p = super::lp::simplex_program(&mut ev, m.goal, x).unwrap();
let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<u64>>();
let rows: Vec<(Vec<u64>, u64)> = p.rows.iter().map(|(a, _, b)| (bits(a), b.to_bits())).collect();
(bits(&p.cf), bits(&p.c), rows, p.work)
};
let mut vertices = Vec::new();
for (x, y) in [("-3", "-5"), ("-3", "2.42"), ("-3", "6.13"), ("-3", "9.84"), ("-3", "13.55"), ("0.37", "0.3"), ("2.55", "-1.2")] {
let mut s = s();
set(&mut s, &[("A1", x), ("A2", y)]);
set(&mut s, &formulas);
let start: Vec<f64> = [x, y].iter().map(|v| v.parse().unwrap()).collect();
let before = program(&s, &start);
let r = s.execute("data.solver", model.clone()).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
// The program the report builds after the solve (cells now at the solution) from the
// solve's `original`, as Solver Results passes it, equals the solve's.
let original: Vec<f64> = ["A1", "A2"].iter().map(|c| r["original"][c].as_f64().unwrap()).collect();
assert_eq!(original, start);
assert_eq!(program(&s, &original), before, "from ({x}, {y})");
let rep = s
.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": r["model"], "values": r["values"], "original": r["original"]}))
.unwrap();
assert_eq!(rep["sheets"], json!(["Sensitivity Report 1"]), "from ({x}, {y}): {rep}");
let v = (r["values"]["A1"].as_f64().unwrap(), r["values"]["A2"].as_f64().unwrap());
vertices.push(if v.0 > v.1 { "(3, 1)" } else { "(1, 3)" });
}
// Both vertices occur, so the starts exercise the tie.
assert!(vertices.contains(&"(3, 1)") && vertices.contains(&"(1, 3)"), "{vertices:?}");
}
#[test]
fn simplex_sensitivity_report_refuses_a_point_that_beats_the_optimum() {
// min 1E+9·A1 with A1 ≥ 0: the optimum is 0. A1 = -1E-7 is outside the bound by less than
// the precision, yet its objective is -100, far from the optimum.
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=1E+9*A1")]);
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex"});
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": -1e-7}})).unwrap_err();
assert!(err.to_string().contains("not optimal"), "{err}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
let r = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": 0}})).unwrap();
assert_eq!(r["sheets"], json!(["Sensitivity Report 1"]), "{r}");
}
#[test]
fn sensitivity_report_of_a_nonlinear_model_gives_lagrange_multipliers() {
// The largest rectangle with perimeter at most 20: the area (P/4)² grows by P/8 = 2.5 per
// unit of perimeter, and both reduced gradients are 0.
let mut s = s();
set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]);
let r =
s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["sensitivity"]})).unwrap();
let rep = "Sensitivity Report 1";
assert_eq!(r["reports"], json!([rep]), "{r}");
assert_eq!(at(&s, rep, &format!("E{}", row_of(&s, rep, "$A$1"))), Value::text("Reduced Gradient"));
assert!(close(&report_row(&s, rep, "$A$1", &["D", "E"]), &[5.0, 0.0], 1e-4));
assert!(close(&report_row(&s, rep, "$B$2", &["D", "E"]), &[20.0, 2.5], 1e-4));
// min (x + 1)² + (y − 2)² with x + y ≤ 1 and x, y ≥ 0: (0, 1). The constraint's
// multiplier is −2; x sits at its bound, where raising it costs 2(x + 1) + 2 = 4.
set(&mut s, &[("C1", "0.5"), ("C2", "0.5"), ("D1", "=(C1+1)^2+(C2-2)^2"), ("D2", "=C1+C2")]);
let r = s
.execute(
"data.solver",
json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "constraints": ["D2 <= 1"], "reports": ["sensitivity"]}),
)
.unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$C$1", &["D", "E"]), &[0.0, 4.0], 1e-4), "{r}");
assert!(close(&report_row(&s, &rep, "$C$2", &["D", "E"]), &[1.0, 0.0], 1e-4));
assert!(close(&report_row(&s, &rep, "$D$2", &["D", "E"]), &[1.0, -2.0], 1e-4));
}
#[test]
fn nonlinear_sensitivity_report_needs_an_optimal_point() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=A1^2")]);
set(&mut s, &[("C1", "0"), ("D1", "=(C1-2)^2")]);
set(&mut s, &[("E1", "1"), ("E2", "1"), ("F1", "=E1*E2"), ("F2", "=2*E1+2*E2")]);
set(&mut s, &[("G1", "0"), ("G2", "1"), ("H1", "=(G1+1)^2+(G2-2)^2"), ("H2", "=G1+G2")]);
let square = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 5"]});
let shifted = json!({"objective": "D1", "goal": "min", "variables": "C1", "constraints": ["C1 <= 5"]});
let rectangle = json!({"objective": "F1", "variables": "E1:E2", "constraints": ["F2 <= 20"]});
let bounded = json!({"objective": "H1", "goal": "min", "variables": "G1:G2", "constraints": ["H2 <= 1"]});
let report = |model: &serde_json::Value, values: serde_json::Value| json!({"reports": ["sensitivity"], "model": model, "values": values});
for (model, values, why) in [
// min A1² at A1 = 1: the free variable's reduced gradient is 2, not 0.
(&square, json!({"A1": 1}), "free gradient"),
// The rectangle at 6 × 6 has a larger area than the optimum but a perimeter of 24 > 20.
(&rectangle, json!({"E1": 6, "E2": 6}), "infeasible"),
// At 4 × 6 the perimeter binds, but its multiplier can't explain the gradient (6, 4).
(&rectangle, json!({"E1": 4, "E2": 6}), "unexplained gradient"),
// min (C1 − 2)² at the bound C1 = 0: raising C1 lowers the objective.
(&shifted, json!({"C1": 0}), "wrong-way bound"),
// (1, 0) in the bounded model: the binding row's multiplier would be +4, the wrong sign
// for a `<=` row when minimising, so the row is dropped and the gradient is unexplained.
(&bounded, json!({"G1": 1, "G2": 0}), "wrong-sign multiplier"),
] {
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", report(model, values)).unwrap_err();
assert!(err.to_string().contains("not optimal"), "{why}: {err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{why}");
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()), "{why}");
}
// The optima are still reported.
for (k, (model, values)) in
[(&square, json!({"A1": 0})), (&shifted, json!({"C1": 2})), (&rectangle, json!({"E1": 5, "E2": 5})), (&bounded, json!({"G1": 0, "G2": 1}))]
.into_iter()
.enumerate()
{
let r = s.execute("data.solverReport", report(model, values)).unwrap();
assert_eq!(r["sheets"], json!([format!("Sensitivity Report {}", k + 1)]), "{r}");
}
}
#[test]
fn solver_writes_the_other_reports_when_the_run_stops_before_an_optimum() {
// Rosenbrock's function from (-1.2, 1) with 60 evaluations: the run stops at the limit with
// a solution that is not the optimum (1, 1). The solve applies and the Answer report is
// written; the Sensitivity report is left out with its reason.
let mut s = s();
set(&mut s, &[("A1", "-1.2"), ("A2", "1"), ("B1", "=(1-A1)^2+100*(A2-A1^2)^2")]);
let undo = s.doc().unwrap().undo.len();
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "nonNegative": false, "maxEvaluations": 60, "reports": ["answer", "sensitivity"]}),
)
.unwrap();
assert_eq!(r["status"], "iterationLimit", "{r}");
assert_eq!(r["changed"], true);
assert_eq!(n(&s, "A1"), r["values"]["A1"].as_f64().unwrap());
assert_eq!(r["reports"], json!(["Answer Report 1"]), "{r}");
assert_eq!(r["skippedReports"][0]["report"], "sensitivity", "{r}");
assert!(r["skippedReports"][0]["reason"].as_str().unwrap().contains("optimal"), "{r}");
assert_eq!(s.doc().unwrap().undo.len(), undo + 1);
// A solve that reaches the optimum still writes the Sensitivity report and skips nothing.
let r = s
.execute("data.solver", json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "nonNegative": false, "reports": ["sensitivity"]}))
.unwrap();
assert_eq!(r["status"], "converged", "{r}");
assert_eq!(r["reports"], json!(["Sensitivity Report 1"]), "{r}");
assert!(r.get("skippedReports").is_none(), "{r}");
}
#[test]
fn solver_report_can_skip_a_sensitivity_report_the_values_do_not_support() {
// min A1² at A1 = 1 is not an optimum. Called directly, data.solverReport refuses it; with
// skipNotOptimal (as Solver Results passes) the Answer report is still written.
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=A1^2")]);
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 5"]});
let p = |reports: serde_json::Value, skip: bool| json!({"reports": reports, "model": model, "values": {"A1": 1}, "skipNotOptimal": skip});
let err = s.execute("data.solverReport", p(json!(["answer", "sensitivity"]), false)).unwrap_err();
assert!(err.to_string().contains("not optimal"), "{err}");
let undo = s.doc().unwrap().undo.len();
let r = s.execute("data.solverReport", p(json!(["answer", "sensitivity"]), true)).unwrap();
assert_eq!(r["sheets"], json!(["Answer Report 1"]), "{r}");
assert!(r["skippedReports"][0]["reason"].as_str().unwrap().contains("not optimal"), "{r}");
assert_eq!(s.doc().unwrap().undo.len(), undo + 1);
// With nothing left to write, nothing changes and no Undo step is added.
let before = s.doc().unwrap().clone();
let r = s.execute("data.solverReport", p(json!(["sensitivity"]), true)).unwrap();
assert_eq!(r["sheets"], json!([]), "{r}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
// A model with no Sensitivity report is still refused, skipNotOptimal or not.
let err = s.execute("data.solverReport", json!({"reports": ["answer", "sensitivity"], "model": {"objective": "B1", "variables": "A1", "constraints": ["A1 <= 5", "A1 int"]}, "skipNotOptimal": true})).unwrap_err();
assert!(err.to_string().contains("integer or binary"), "{err}");
// In a workbook whose structure is protected, a request whose only report is skipped
// writes no sheet, so it succeeds; one that would write a sheet is still refused.
let d = s.doc_mut().unwrap();
std::sync::Arc::make_mut(&mut d.wb).protected_structure = true;
let before = s.doc().unwrap().clone();
let r = s.execute("data.solverReport", p(json!(["sensitivity"]), true)).unwrap();
assert_eq!(r["sheets"], json!([]), "{r}");
assert!(r["skippedReports"][0]["reason"].as_str().unwrap().contains("not optimal"), "{r}");
let err = s.execute("data.solverReport", p(json!(["answer", "sensitivity"]), true)).unwrap_err();
assert!(err.to_string().contains("protected"), "{err}");
// A request that must write a sheet is refused before its reports are built, so an
// integer model's Limits report gives the protection message, not the report's own refusal.
let int_model = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 5", "A1 int"]});
let err = s.execute("data.solverReport", json!({"reports": ["limits"], "model": int_model, "values": {"A1": 1}})).unwrap_err();
assert!(err.to_string().contains("protected"), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
}
#[test]
fn a_sensitivity_refusal_of_the_model_comes_before_any_report_is_built() {
// The refusals that apply to the model (integer or binary constraints, the Evolutionary
// method, a Value Of goal) are checked before any report is built, in both modes and from
// both commands: a Limits report listed first would otherwise run its two optimisations per
// variable, and data.solver its whole solve, for an error. An integer model asking for
// limits and sensitivity gets the Sensitivity refusal, not the Limits report's, which the
// report loop reached first; a Value Of model, whose Limits report is not refused, gets the
// Sensitivity refusal with no sheet written and the workbook as it was.
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
let before = s.doc().unwrap().clone();
let int_model = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 5", "A1 int"]});
let value_model = json!({"objective": "B1", "goal": "value", "value": 4, "variables": "A1", "constraints": ["A1 <= 5"]});
for (model, values, why) in
[(&int_model, json!({"A1": 1}), "The Sensitivity report doesn't apply"), (&value_model, json!({"A1": 2}), "maximise or minimise")]
{
for skip in [false, true] {
let p = json!({"reports": ["limits", "sensitivity"], "model": model, "values": values, "skipNotOptimal": skip});
let err = s.execute("data.solverReport", p).unwrap_err();
assert!(err.to_string().contains(why), "{skip}: {err}");
}
}
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 5", "A1 int"], "method": "simplex", "reports": ["limits", "sensitivity"]});
let err = s.execute("data.solver", p).unwrap_err();
assert!(err.to_string().contains("The Sensitivity report doesn't apply"), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
}
#[test]
fn nonlinear_sensitivity_with_too_many_binding_constraints_is_refused_without_change() {
// 80 binding rows on two free variables pass the work limit (lowered in tests); 40 don't.
let mut s = s();
set(&mut s, &[("A1", "5"), ("A2", "5"), ("B1", "=A1*A2")]);
for r in 1..=80 {
set(&mut s, &[(&format!("D{r}"), "=A1+A2")]);
}
let report = |rows: u32| json!({"reports": ["sensitivity"], "model": {"objective": "B1", "variables": "A1:A2", "constraints": [format!("D1:D{rows} <= 10")]}});
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", report(80)).unwrap_err();
assert!(err.to_string().contains("too many binding constraints"), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
let r = s.execute("data.solverReport", report(40)).unwrap();
assert_eq!(r["sheets"], json!(["Sensitivity Report 1"]), "{r}");
}
#[test]
fn sensitivity_applies_agrees_with_the_report() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=3*A1")]);
for (extra, ok) in [
(json!({}), true),
(json!({"goal": "min"}), true),
(json!({"method": "grg"}), true),
(json!({"goal": "value", "value": 3}), false),
(json!({"constraints": ["A1 <= 4", "A1 int"]}), false),
(json!({"constraints": ["A1 <= 4", {"lhs": "A1", "op": "bin"}]}), false),
(json!({"method": "evolutionary"}), false),
] {
let mut p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", "keep": false});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
assert_eq!(super::sensitivity_applies(&p), ok, "{p}");
let r = s.execute("data.solver", p.clone()).unwrap();
assert_eq!(super::sensitivity_applies(&r["model"]), ok, "{p}");
let rep = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": r["model"], "values": r["values"]}));
assert_eq!(rep.is_ok(), ok, "{p}: {rep:?}");
}
}
/// How many cells of sheet `name` hold `text`.
fn count_of(s: &Session, name: &str, text: &str) -> usize {
let d = s.doc().unwrap();
let sh = d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap();
(0..60).flat_map(|r| (0..8).map(move |c| (r, c))).filter(|(r, c)| sh.value(CellRef::new(*r, *c)) == Value::text(text)).count()
}
#[test]
fn sensitivity_report_is_the_same_whatever_constant_the_objective_adds() {
// A constant in the objective changes no price: max 1E9 + x with x ≤ 10 (a row) has the
// same report as max x with x ≤ 10. At x = 10 the row's shadow price is 1 (one more unit
// of right side adds one to the objective); x is basic, with reduced cost 0 and
// coefficient 1, which may rise without limit and fall by 1 (to 0, where the slack ties);
// the right side 10 may rise without limit and fall by 10 (to x = 0). A tolerance taken
// from the objective's value (1E-9 · 1E9 = 1) wrote the shadow price as 0.
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=1000000000+A1"), ("C1", "=A1"), ("E1", "0"), ("F1", "=E1"), ("G1", "=E1")]);
let cols = ["D", "E", "F", "G", "H"];
let mut rows = Vec::new();
for (obj, var, con, v, c) in [("B1", "A1", "C1", "$A$1", "$C$1"), ("F1", "E1", "G1", "$E$1", "$G$1")] {
let p = json!({"objective": obj, "variables": var, "constraints": [format!("{con} <= 10")], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
rows.push((report_row(&s, &rep, v, &cols), report_row(&s, &rep, c, &cols)));
}
assert!(close(&rows[0].0, &[10.0, 0.0, 1.0, 1e30, 1.0], 1e-9), "{rows:?}");
assert!(close(&rows[0].1, &[10.0, 1.0, 10.0, 1e30, 10.0], 1e-9), "{rows:?}");
assert_eq!(rows[0], rows[1]);
}
#[test]
fn nonlinear_sensitivity_report_is_the_same_whatever_constant_the_objective_adds() {
// min 1E6 + (x − 3)² with x ≤ 2.75 has the same report as min (x − 3)² with x ≤ 2.75. At
// x = 2.75 the objective's gradient is 2(2.75 − 3) = −0.5. As a bound on the variable cell
// (no constraint row) it is x's reduced gradient, −0.5; as a row y = x ≤ 2.75 it is the
// row's Lagrange multiplier, −0.5 (∇f = λ∇y with ∇y = 1), and x's reduced gradient is 0.
// A tolerance taken from the objective's value (1E-6 · 1E6 = 1) wrote both as 0. The
// difference gradients of the model with the constant lose about four digits to rounding,
// so the figures agree to 1E-3.
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=1000000+(A1-3)^2"), ("C1", "=A1"), ("E1", "0"), ("F1", "=(E1-3)^2"), ("G1", "=E1")]);
let mut figures = Vec::new();
for (obj, var, con, v, c) in [("B1", "A1", "C1", "$A$1", "$C$1"), ("F1", "E1", "G1", "$E$1", "$G$1")] {
let solve = |s: &mut Session, bound: String| {
let p = json!({"objective": obj, "goal": "min", "variables": var, "constraints": [bound], "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "converged", "{r}");
r["reports"][0].as_str().unwrap().to_string()
};
let rep = solve(&mut s, format!("{var} <= 2.75"));
let at_bound = report_row(&s, &rep, v, &["D", "E"]);
assert_eq!(count_of(&s, &rep, c), 0, "a bound has no constraint row");
let rep = solve(&mut s, format!("{con} <= 2.75"));
figures.push((at_bound, report_row(&s, &rep, v, &["D", "E"]), report_row(&s, &rep, c, &["D", "E"])));
}
let (at_bound, free, row) = &figures[0];
assert!(close(at_bound, &[2.75, -0.5], 1e-3), "{figures:?}");
assert!(close(free, &[2.75, 0.0], 1e-3), "{figures:?}");
assert!(close(row, &[2.75, -0.5], 1e-3), "{figures:?}");
assert!(close(&figures[1].0, at_bound, 1e-3) && close(&figures[1].1, free, 1e-3) && close(&figures[1].2, row, 1e-3), "{figures:?}");
}
#[test]
fn simplex_sensitivity_keeps_a_small_price_beside_a_large_coefficient() {
// max 1E9·x + y with x ≤ 1 and y ≤ 1 as rows and x, y ≥ 0: (1, 1). Both rows bind, the
// basis is {x, y} with B = I, so the shadow prices are the coefficients, 1E9 and 1, and both
// reduced costs are 0. x's coefficient may rise without limit and fall by 1E9 (to 0, where
// its row's slack ties), y's without limit and by 1. Each right side may rise without limit
// (the other row doesn't involve the variable) and fall by 1 (to 0). The second row's price
// is the single term 1 · 1, so the first row's coefficient 1E9 is no rounding error in it; a
// noise level taken from the largest coefficient (1E-9 · 1E9 = 1) wrote it as 0.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=1000000000*A1+A2"), ("C1", "=A1"), ("C2", "=A2")]);
let p = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["C1 <= 1", "C2 <= 1"], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
let cols = ["D", "E", "F", "G", "H"];
let row = |c: &str| report_row(&s, &rep, c, &cols);
assert!(close(&row("$A$1"), &[1.0, 0.0, 1e9, 1e30, 1e9], 1e-9), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[1.0, 0.0, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$A$2"));
assert!(close(&row("$C$1"), &[1.0, 1e9, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$C$1"));
assert!(close(&row("$C$2"), &[1.0, 1.0, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$C$2"));
}
#[test]
fn simplex_sensitivity_keeps_a_small_reduced_cost_beside_a_large_coefficient() {
// max 1E9·x + y − z with x ≤ 1 and y ≤ 1 as rows and x, y, z ≥ 0 (z's bound is the only
// constraint on it): (1, 1, 0). z is nonbasic at zero with reduced cost −1 − 0 = −1 (no
// row involves it): its coefficient must rise by 1 before z is made, so the Allowable
// Increase is 1 and the Allowable Decrease 1E+30, as for a maximum's variable at zero in
// `simplex_sensitivity_gives_the_dakota_furniture_report`. The other rows are those of
// `simplex_sensitivity_keeps_a_small_price_beside_a_large_coefficient`. The reduced cost
// is the coefficient alone, so x's coefficient 1E9 is no rounding error in it; a noise
// level taken from the largest coefficient wrote it as 0.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0"), ("B1", "=1000000000*A1+A2-A3"), ("C1", "=A1"), ("C2", "=A2")]);
let p = json!({"objective": "B1", "variables": "A1:A3", "constraints": ["C1 <= 1", "C2 <= 1"], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
let cols = ["D", "E", "F", "G", "H"];
let row = |c: &str| report_row(&s, &rep, c, &cols);
assert!(close(&row("$A$1"), &[1.0, 0.0, 1e9, 1e30, 1e9], 1e-9), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[1.0, 0.0, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$A$2"));
assert!(close(&row("$A$3"), &[0.0, -1.0, -1.0, 1.0, 1e30], 1e-9), "{:?}", row("$A$3"));
assert!(close(&row("$C$1"), &[1.0, 1e9, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$C$1"));
assert!(close(&row("$C$2"), &[1.0, 1.0, 1.0, 1e30, 1.0], 1e-9), "{:?}", row("$C$2"));
}
#[test]
fn nonlinear_sensitivity_keeps_a_small_multiplier_beside_a_large_gradient() {
// The model of `simplex_sensitivity_keeps_a_small_price_beside_a_large_coefficient` under
// GRG Nonlinear: at (1, 1) both rows bind; with ∇f = (1E9, 1) and unit row gradients the
// Lagrange multipliers are 1E9 and 1, and both reduced gradients are 0. The second row's
// multiplier is the single term 1 · 1, so the gradient's large first component is no
// rounding error in it; a noise level taken from the gradient's size (1E-6 · 1E9 = 1000)
// wrote it as 0. The difference gradient of y is the difference of two values near 1E9,
// where the last place is 2⁻²² ≈ 2.4E-7: the step 1E-6 rounds to 4 places, 9.54E-7, so
// df_y ≈ 0.954 and the multiplier is checked to 0.1. The nonlinear method stops on this
// model from (0, 0) with "unbounded", so the report is asked for at the optimum (1, 1).
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=1000000000*A1+A2"), ("C1", "=A1"), ("C2", "=A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["C1 <= 1", "C2 <= 1"]});
let r = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": 1, "A2": 1}})).unwrap();
let rep = r["sheets"][0].as_str().unwrap().to_string();
let row = |c: &str| report_row(&s, &rep, c, &["D", "E"]);
assert!(close(&row("$A$1"), &[1.0, 0.0], 1e-3), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[1.0, 0.0], 1e-3), "{:?}", row("$A$2"));
assert!(close(&row("$C$1"), &[1.0, 1e9], 1e-3), "{:?}", row("$C$1"));
assert!(close(&row("$C$2"), &[1.0, 1.0], 0.1), "{:?}", row("$C$2"));
}
#[test]
fn nonlinear_sensitivity_keeps_a_small_reduced_gradient_beside_a_large_gradient() {
// The model of `simplex_sensitivity_keeps_a_small_reduced_cost_beside_a_large_coefficient`
// under GRG Nonlinear: z sits at its bound 0, where its reduced gradient is its own
// gradient −1 (no row involves it); a noise level taken from the gradient's size (1000)
// wrote it as 0. Its difference gradient is one-sided at the bound, over the step 1E-6
// from a value near 1E9 that rounds as in
// `nonlinear_sensitivity_keeps_a_small_multiplier_beside_a_large_gradient`: about −0.954,
// checked to 0.1. As there, the report is asked for at the optimum (1, 1, 0).
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0"), ("B1", "=1000000000*A1+A2-A3"), ("C1", "=A1"), ("C2", "=A2")]);
let model = json!({"objective": "B1", "variables": "A1:A3", "constraints": ["C1 <= 1", "C2 <= 1"]});
let r = s.execute("data.solverReport", json!({"reports": ["sensitivity"], "model": model, "values": {"A1": 1, "A2": 1, "A3": 0}})).unwrap();
let rep = r["sheets"][0].as_str().unwrap().to_string();
let row = |c: &str| report_row(&s, &rep, c, &["D", "E"]);
assert!(close(&row("$A$1"), &[1.0, 0.0], 1e-3), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[1.0, 0.0], 1e-3), "{:?}", row("$A$2"));
assert!(close(&row("$A$3"), &[0.0, -1.0], 0.1), "{:?}", row("$A$3"));
assert!(close(&row("$C$1"), &[1.0, 1e9], 1e-3), "{:?}", row("$C$1"));
assert!(close(&row("$C$2"), &[1.0, 1.0], 0.1), "{:?}", row("$C$2"));
}
#[test]
fn nonlinear_sensitivity_refuses_a_point_that_is_not_optimal_in_a_small_variable() {
// max 1E9·x + y with x ≤ 1 and y ≤ 1 as rows, at (1, 0.5): the second row doesn't bind, y
// is free and its reduced gradient is its own gradient, about 1 (0.954 with the rounding
// in `nonlinear_sensitivity_keeps_a_small_multiplier_beside_a_large_gradient`), so raising
// y to 1 gains 0.5 and the point is not optimal: the report is refused and the workbook
// left as it was. A stationarity tolerance taken from the largest gradient component
// (1E-3 · 1E9 = 1E6) accepted the point and wrote the report. The optimum (1, 1) is
// reported.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=1000000000*A1+A2"), ("C1", "=A1"), ("C2", "=A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["C1 <= 1", "C2 <= 1"]});
let report = |values: serde_json::Value| json!({"reports": ["sensitivity"], "model": model, "values": values});
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", report(json!({"A1": 1, "A2": 0.5}))).unwrap_err();
assert!(err.to_string().contains("not optimal"), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
let r = s.execute("data.solverReport", report(json!({"A1": 1, "A2": 1}))).unwrap();
assert_eq!(r["sheets"], json!(["Sensitivity Report 1"]), "{r}");
}
#[test]
fn nonlinear_sensitivity_refuses_a_point_that_breaks_a_small_row_beside_a_large_one() {
// max y − (x − 2)² with x ≤ 1E9 and y ≤ 1 as rows: (2, 1). At (2, 1.0001) the second row
// is over by 1E-4, more than ten times the constraint precision of its own right side
// (1E-5 · (1 + 1) = 2E-5), so the point is infeasible and the report is refused; it is
// stationary (x's gradient is 0 and the row, within the binding tolerance, explains y's),
// so feasibility alone decides. A feasibility tolerance relative to the largest constant
// right side (1E-5 · 1E9 = 1E4) accepted the point. The optimum (2, 1) is reported.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=A2-(A1-2)^2"), ("C1", "=A1"), ("C2", "=A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["C1 <= 1000000000", "C2 <= 1"]});
let report = |values: serde_json::Value| json!({"reports": ["sensitivity"], "model": model, "values": values});
let before = s.doc().unwrap().clone();
let err = s.execute("data.solverReport", report(json!({"A1": 2, "A2": 1.0001}))).unwrap_err();
assert!(err.to_string().contains("not optimal"), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!((d.undo.len(), d.redo.len()), (before.undo.len(), before.redo.len()));
let r = s.execute("data.solverReport", report(json!({"A1": 2, "A2": 1}))).unwrap();
assert_eq!(r["sheets"], json!(["Sensitivity Report 1"]), "{r}");
assert!(close(&report_row(&s, "Sensitivity Report 1", "$C$2", &["D", "E"]), &[1.0, 1.0], 1e-3), "{r}");
}
#[test]
fn simplex_sensitivity_of_a_variable_at_its_upper_bound() {
// max 3x + 2y with x + y ≤ 4 (a row), x ≤ 3 and y ≤ 5 (bounds on the variable cells, which
// have no row in the Constraints table): x = 3, y = 1, objective 11. The row's shadow price
// is y's coefficient 2 (y is basic in it). x, held at its upper bound, has reduced cost
// 3 − 1·2 = 1: one more unit of its bound adds 1. This is the convention of Frontline's
// "Excel Solver - Interpreting the Sensitivity Report" (solver.com): a variable's dual
// value is nonzero only at a bound, measures the objective's change per unit increase in
// the variable, and a bound on a variable cell has no row in the Constraints section, its
// reduced cost standing for the bound's shadow price. Its coefficient may rise without limit
// and fall by 1 (to 2, a tie with y): for a maximum's variable at its upper bound the
// Allowable Increase is 1E+30 and the Allowable Decrease the reduced cost, the mirror
// image of a variable at zero (`simplex_sensitivity_gives_the_dakota_furniture_report`).
// y's coefficient may rise by 1 (to 3, a tie with x) and fall by 2 (to 0, where the row's
// slack ties). The right side 4 may rise by 4 (y reaches its bound 5 at 8) and fall by 1
// (y reaches 0 at 3).
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+2*A2"), ("C1", "=A1+A2")]);
let p = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["C1 <= 4", "A1 <= 3", "A2 <= 5"], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
let cols = ["D", "E", "F", "G", "H"];
let row = |c: &str| report_row(&s, &rep, c, &cols);
assert!(close(&row("$A$1"), &[3.0, 1.0, 3.0, 1e30, 1.0], 1e-9), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[1.0, 0.0, 2.0, 1.0, 2.0], 1e-9), "{:?}", row("$A$2"));
assert!(close(&row("$C$1"), &[4.0, 2.0, 4.0, 4.0, 1.0], 1e-9), "{:?}", row("$C$1"));
assert_eq!((count_of(&s, &rep, "$A$1"), count_of(&s, &rep, "$A$2"), count_of(&s, &rep, "$C$1")), (1, 1, 1));
}
#[test]
fn simplex_sensitivity_of_a_minimum_with_a_greater_row_and_a_bound() {
// min 2x + 3y with x + y ≥ 5 (a row) and x ≤ 2 (a bound): x = 2, y = 3, cost 13. The row's
// shadow price is 3: one more unit of right side costs one more y. x, at its upper bound,
// has reduced cost 2 − 1·3 = −1 (one more unit of its bound saves 1). Its coefficient may
// rise by 1 (to 3, a tie with y) and fall without limit: for a minimum's variable at its
// upper bound the Allowable Increase is the reduced cost's size and the Allowable Decrease
// 1E+30, the mirror image of a minimum's variable at zero (the reduced cost 1 with
// Allowable Increase 1E+30 and Allowable Decrease 1 in
// `sensitivity_report_gives_the_wyndor_glass_shadow_prices_and_ranges`). y's coefficient
// may rise without limit (x can take no more) and fall by 1 (to 2, a tie with x). The right
// side 5 may rise without limit and fall by 3 (y reaches 0 at 2).
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=2*A1+3*A2"), ("C1", "=A1+A2")]);
let p = json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["C1 >= 5", "A1 <= 2"], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
let cols = ["D", "E", "F", "G", "H"];
let row = |c: &str| report_row(&s, &rep, c, &cols);
assert!(close(&row("$A$1"), &[2.0, -1.0, 2.0, 1.0, 1e30], 1e-9), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[3.0, 0.0, 3.0, 1e30, 1.0], 1e-9), "{:?}", row("$A$2"));
assert!(close(&row("$C$1"), &[5.0, 3.0, 5.0, 1e30, 3.0], 1e-9), "{:?}", row("$C$1"));
}
#[test]
fn simplex_sensitivity_gives_the_dakota_furniture_report() {
// Winston, Operations Research: Applications and Algorithms, the Dakota Furniture problem:
// max 60x₁ + 30x₂ + 20x₃ (desks, tables, chairs) with 8x₁ + 6x₂ + x₃ ≤ 48 (lumber),
// 4x₁ + 2x₂ + 1.5x₃ ≤ 20 (finishing) and 2x₁ + 1.5x₂ + 0.5x₃ ≤ 8 (carpentry): x = (2, 0, 8),
// revenue 280. For the basis {s₁, x₃, x₁} the rows of B⁻¹ are (1, 2, −8), (0, 2, −4) and
// (0, −0.5, 1.5), so the basic values are B⁻¹b = (24, 8, 2) and the shadow prices
// c_B·B⁻¹ = (0, 20, 60)·B⁻¹ = (0, 10, 10). Tables, at zero, have reduced cost
// 30 − (6·0 + 2·10 + 1.5·10) = −5: their price must rise by 5 before they are made, so the
// Allowable Increase is 5 and the Allowable Decrease 1E+30, as Excel's report shows a
// maximum's variable at zero. A change Δ in a basic variable's coefficient moves each
// nonbasic reduced cost by Δ times that variable's row of the final tableau; the tables'
// column there is B⁻¹(6, 2, 1.5) = (−2, −2, 1.25) and the slacks' columns are B⁻¹'s. Desks
// (row 3): −5 − 1.25Δ ≤ 0, −10 + 0.5Δ ≤ 0 and −10 − 1.5Δ ≤ 0 hold for −4 ≤ Δ ≤ 20. Chairs
// (row 2): −5 + 2Δ ≤ 0, −10 − 2Δ ≤ 0 and −10 + 4Δ ≤ 0 hold for −5 ≤ Δ ≤ 2.5. A right side
// moves the basic values along its column of B⁻¹: lumber (24 + Δ, 8, 2) ≥ 0 for Δ ≥ −24;
// finishing (24 + 2Δ, 8 + 2Δ, 2 − 0.5Δ) ≥ 0 for −4 ≤ Δ ≤ 4; carpentry
// (24 − 8Δ, 8 − 4Δ, 2 + 1.5Δ) ≥ 0 for −4/3 ≤ Δ ≤ 2.
let mut s = s();
set(
&mut s,
&[
("A1", "0"),
("A2", "0"),
("A3", "0"),
("B1", "=60*A1+30*A2+20*A3"),
("C1", "=8*A1+6*A2+A3"),
("C2", "=4*A1+2*A2+1.5*A3"),
("C3", "=2*A1+1.5*A2+0.5*A3"),
],
);
let p = json!({"objective": "B1", "variables": "A1:A3", "constraints": ["C1 <= 48", "C2 <= 20", "C3 <= 8"], "method": "simplex", "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal", "{r}");
assert_eq!(r["objective"], 280.0, "{r}");
let rep = r["reports"][0].as_str().unwrap().to_string();
let cols = ["D", "E", "F", "G", "H"];
let row = |c: &str| report_row(&s, &rep, c, &cols);
assert!(close(&row("$A$1"), &[2.0, 0.0, 60.0, 20.0, 4.0], 1e-9), "{:?}", row("$A$1"));
assert!(close(&row("$A$2"), &[0.0, -5.0, 30.0, 5.0, 1e30], 1e-9), "{:?}", row("$A$2"));
assert!(close(&row("$A$3"), &[8.0, 0.0, 20.0, 2.5, 5.0], 1e-9), "{:?}", row("$A$3"));
assert!(close(&row("$C$1"), &[24.0, 0.0, 48.0, 1e30, 24.0], 1e-9), "{:?}", row("$C$1"));
assert!(close(&row("$C$2"), &[20.0, 10.0, 20.0, 4.0, 4.0], 1e-9), "{:?}", row("$C$2"));
assert!(close(&row("$C$3"), &[8.0, 10.0, 8.0, 2.0, 4.0 / 3.0], 1e-9), "{:?}", row("$C$3"));
}
#[test]
fn nonlinear_sensitivity_signs_follow_the_goal() {
// A Lagrange multiplier is the derivative of the optimal objective with respect to the
// row's right side, and a reduced gradient that with respect to the variable's bound,
// whichever way the goal points. Frontline's "Excel Solver - Interpreting the Sensitivity
// Report" (solver.com) names a binding constraint's dual value its Lagrange multiplier and a
// variable's its reduced gradient, each the objective's change per unit increase in the
// constraint's bound or the variable, valid at the optimal point alone for a nonlinear
// model.
// min (x − 3)² + (y − 1)² with x + y ≤ b: the point of the line nearest (3, 1) is
// (2.5, 0.5) at b = 3, f*(b) = (4 − b)²/2 and df*/db = −(4 − b) = −1, the multiplier;
// maximising the negated objective reaches the same point with multiplier +1. Both
// variables are interior, so their reduced gradients are 0.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=(A1-3)^2+(A2-1)^2"), ("B2", "=0-(A1-3)^2-(A2-1)^2"), ("C1", "=A1+A2")]);
for (obj, goal, lambda) in [("B1", "min", -1.0), ("B2", "max", 1.0)] {
set(&mut s, &[("A1", "0"), ("A2", "0")]);
let p = json!({"objective": obj, "goal": goal, "variables": "A1:A2", "constraints": ["C1 <= 3"], "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$A$1", &["D", "E"]), &[2.5, 0.0], 1e-3), "{goal}: {r}");
assert!(close(&report_row(&s, &rep, "$A$2", &["D", "E"]), &[0.5, 0.0], 1e-3), "{goal}: {r}");
assert!(close(&report_row(&s, &rep, "$C$1", &["D", "E"]), &[3.0, lambda], 1e-3), "{goal}: {r}");
}
// max −(x + 1)² − (y − 2)² with x + y ≤ 1 and x, y ≥ 0 mirrors the minimum in
// `sensitivity_report_of_a_nonlinear_model_gives_lagrange_multipliers`: at (0, 1) the
// multiplier is +2 (−2 there) and x's reduced gradient −4 (4 there), since raising x from
// its bound lowers the maximum by 2(x + 1) + 2 = 4 per unit.
set(&mut s, &[("D1", "0.5"), ("D2", "0.5"), ("E1", "=0-(D1+1)^2-(D2-2)^2"), ("E2", "=D1+D2")]);
let p = json!({"objective": "E1", "goal": "max", "variables": "D1:D2", "constraints": ["E2 <= 1"], "reports": ["sensitivity"]});
let r = s.execute("data.solver", p).unwrap();
let rep = r["reports"][0].as_str().unwrap().to_string();
assert!(close(&report_row(&s, &rep, "$D$1", &["D", "E"]), &[0.0, -4.0], 1e-3), "{r}");
assert!(close(&report_row(&s, &rep, "$D$2", &["D", "E"]), &[1.0, 0.0], 1e-3), "{r}");
assert!(close(&report_row(&s, &rep, "$E$2", &["D", "E"]), &[1.0, 2.0], 1e-3), "{r}");
}
fn formula(s: &Session, a1: &str) -> String {
let d = s.doc().unwrap();
d.wb.active().unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
+1
View File
@@ -5244,6 +5244,7 @@ fn a_script_cannot_switch_windows() {
}
mod macros;
mod objects;
mod python_cells;
mod python_commands;
mod python_files;
+361
View File
@@ -0,0 +1,361 @@
//! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`.
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, AnchorMode, ObjKind, ObjRef, Sheet};
use serde_json::json;
use super::s;
use crate::Session;
fn at(a: &str) -> CellRef {
CellRef::parse(a).unwrap()
}
fn sheet(s: &Session, i: usize) -> &Sheet {
s.doc().unwrap().wb.sheet(i).unwrap()
}
fn id_of(v: &serde_json::Value, key: &str) -> u32 {
v[key].as_u64().unwrap() as u32
}
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a
/// slicer, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 4] {
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
let image = super::add_image(s, "D18", AnchorMode::MoveAndSize);
let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape");
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
[ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)]
}
#[test]
fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
let mut s = s();
let objs = every_kind(&mut s);
for o in objs {
assert_eq!(sheet(&s, 0).object_ref(o.id), Some(o), "the id finds its kind");
assert_eq!(ObjKind::parse(o.kind.name()), Some(o.kind));
s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "at": "M20", "width": 50, "height": 30})).unwrap();
let a = sheet(&s, 0).object_anchor(o).unwrap();
assert_eq!((a.cell, a.width, a.height), (at("M20"), 50.0, 30.0));
s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap();
assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute);
}
// A kind that does not match the id, an unknown id or kind, and a missing kind are refused.
assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err());
assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err());
assert!(s.execute("object.delete", json!({"kind": "widget", "id": objs[0].id})).is_err());
assert!(s.execute("object.delete", json!({"id": objs[0].id})).is_err(), "kind is required");
for (n, o) in objs.into_iter().enumerate() {
let out = s.execute("object.delete", json!({"kind": o.kind.name(), "id": o.id})).unwrap();
assert_eq!(out["deleted"], json!({"kind": o.kind.name(), "id": o.id}));
assert!(!sheet(&s, 0).has_object(o));
for later in &objs[n + 1..] {
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
}
}
// One undo step per delete brings the slicer back.
s.execute("edit.undo", json!({})).unwrap();
assert!(sheet(&s, 0).has_object(objs[3]));
}
#[test]
fn selection_pane_lists_and_stacks_slicers() {
let mut s = s();
let objs = every_kind(&mut s);
let second = id_of(&s.execute("insert.slicer", json!({"column": "Qty", "at": "P2"})).unwrap(), "slicer");
let list = s.execute("arrange.selectionPane", json!({})).unwrap();
let items = list.as_array().unwrap();
let kinds: Vec<&str> = items.iter().map(|o| o["kind"].as_str().unwrap()).collect();
for k in ObjKind::ALL {
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
}
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
}
#[test]
fn hostile_object_params_are_refused() {
let mut s = s();
let objs = every_kind(&mut s);
for p in [
json!({}),
json!({"kind": "chart"}),
json!({"kind": "chart", "id": -1}),
json!({"kind": "chart", "id": u64::MAX}),
json!({"kind": 7, "id": objs[0].id}),
json!({"kind": null, "id": objs[0].id}),
] {
for cmd in ["object.delete", "object.move", "object.setAnchorMode"] {
assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");
}
}
let o = objs[3];
s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "dx": 1e300, "dy": -1e300, "width": 1e300, "height": -5})).unwrap();
let a = sheet(&s, 0).object_anchor(o).unwrap();
assert!(a.dx.is_finite() && a.dy.is_finite() && a.width <= 10000.0 && a.height >= 0.0, "{a:?}");
}
fn cell_of(s: &Session, o: ObjRef) -> String {
sheet(s, 0).object_anchor(o).unwrap().cell.a1()
}
#[test]
fn every_kind_follows_row_and_column_edits_and_sorts() {
let mut s = s();
let objs = every_kind(&mut s);
// Anchors: chart D2, picture D18, shape H18, slicer L2.
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays");
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]);
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
// Sorting moves a picture anchored inside the sorted block with its row.
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
s.execute("selection.set", json!({"range": "T1:T4"})).unwrap();
s.execute("data.sortAscending", json!({"header": true, "column": "T"})).unwrap();
assert_eq!(cell_of(&s, pic), "T4", "the row holding 3 sorts last");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cell_of(&s, pic), "T2");
}
/// A picture at `cell` with anchor `mode`, offset `(dx, dy)` and size `(width, height)`, added
/// straight to the model like `add_image`, so a sort that follows is the only step to undo.
fn picture(s: &mut Session, cell: &str, mode: AnchorMode, dx: f32, dy: f32, width: f32, height: f32) -> ObjRef {
let id = super::add_image(s, cell, mode);
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
if let Some(img) = wb.sheet_mut(0).unwrap().images.iter_mut().find(|i| i.id == id) {
img.anchor = Anchor { dx, dy, width, height, ..img.anchor };
}
ObjRef::new(ObjKind::Image, id)
}
/// The anchor of `o` as (cell, dx, dy, width, height).
fn anchor_of(s: &Session, o: ObjRef) -> (String, f32, f32, f32, f32) {
let a = sheet(s, 0).object_anchor(o).unwrap();
(a.cell.a1(), a.dx, a.dy, a.width, a.height)
}
/// Sorting rows under a picture follows its anchor mode, as Excel's "Move and size with cells",
/// "Move but don't size with cells" and "Don't move or size with cells" do.
#[test]
fn sorting_rows_under_a_picture_follows_its_anchor_mode() {
let mut s = s();
// Rows 2..5 hold 3, 1, 4, 2; sorted ascending they read 1, 2, 3, 4, so row 2 moves to row 4,
// row 3 to row 2, row 4 to row 5 and row 5 to row 3. Default rows are 20 pt tall.
s.execute("range.setValues", json!({"range": "A1", "values": [["k"], [3], [1], [4], [2]]})).unwrap();
// Three pictures inside one row each (3 pt down, 12 pt tall), one per anchor mode.
let sized = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
let moved = picture(&mut s, "A3", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
let pinned = picture(&mut s, "A4", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
// A "move and size" picture spanning rows 2 and 3: its top is 10 pt into row 2 (y 30) and
// its bottom 15 pt into row 3 (y 55). The sort puts row 3 above row 2.
let tall = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 10.0, 30.0, 25.0);
let before: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
s.execute("selection.set", json!({"range": "A1:A5"})).unwrap();
s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap();
assert_eq!(
anchor_of(&s, sized),
("A4".into(), 5.0, 3.0, 30.0, 12.0),
"move and size: travels with row 2 to row 4, re-fitted to the same 20 pt row"
);
assert_eq!(anchor_of(&s, moved), ("A2".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with row 3 to row 2 at its own size");
assert_eq!(anchor_of(&s, pinned), ("A4".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays while the cells move under it");
// The tall picture's bottom row now sorts above its top row, so there is nothing to stretch
// it between: it travels with its top-left row and keeps its size instead of collapsing.
assert_eq!(anchor_of(&s, tall), ("A4".into(), 5.0, 10.0, 30.0, 25.0), "two rows swapped: moves with its top row at its old size");
s.execute("edit.undo", json!({})).unwrap();
let after: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
assert_eq!(after, before, "undo restores every anchor");
}
/// A sort by columns follows the anchor mode the same way.
#[test]
fn sorting_columns_under_a_picture_follows_its_anchor_mode() {
let mut s = s();
// Row 10 holds a header in A and 3, 1, 2 in B..D; sorted ascending, column B moves to D,
// C to B and D to C. Default columns are 64 pt wide.
s.execute("range.setValues", json!({"range": "A10", "values": [["k", 3, 1, 2]]})).unwrap();
let sized = picture(&mut s, "B10", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
let moved = picture(&mut s, "C10", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
let pinned = picture(&mut s, "D10", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
// A "move and size" picture spanning columns B and C: from x 104 (40 pt into B) to x 154
// (26 pt into C). The sort puts column C before column B.
let wide = picture(&mut s, "B10", AnchorMode::MoveAndSize, 40.0, 3.0, 50.0, 12.0);
let before: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
s.execute("data.sort", json!({"range": "A10:D10", "header": true, "orientation": "columns", "keys": [{"row": 10}]})).unwrap();
assert_eq!(anchor_of(&s, sized), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "move and size: travels with column B to D at the same width");
assert_eq!(anchor_of(&s, moved), ("B10".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with column C to B at its own size");
assert_eq!(anchor_of(&s, pinned), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays");
assert_eq!(anchor_of(&s, wide), ("D10".into(), 40.0, 3.0, 50.0, 12.0), "two columns swapped: moves with its left column at its old size");
s.execute("edit.undo", json!({})).unwrap();
let after: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
assert_eq!(after, before, "undo restores every anchor");
}
#[test]
fn slicers_follow_inserted_rows_like_other_objects() {
let mut s = s();
let objs = every_kind(&mut s);
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
assert_eq!(cell_of(&s, objs[3]), "L4");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cell_of(&s, objs[3]), "L2");
}
#[test]
fn chart_series_follow_cut_moves_renames_and_sheet_deletes() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [5], [2], [8]]})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A4", "type": "column"})).unwrap(), "chart");
let values = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).map(|c| c.series[0].values.clone()).unwrap_or_default();
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
assert!(values(&s).contains("$F$2:$F$5"), "a cut-move takes the series with the cells: {}", values(&s));
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts[0].series[0].values = "Other!$A$1:$A$3".into();
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
assert!(values(&s).contains("#REF!"), "a series into a deleted sheet becomes #REF!: {}", values(&s));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
}
#[test]
fn a_copied_sheet_gives_its_objects_fresh_ids() {
let mut s = s();
let objs = every_kind(&mut s);
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &s.doc().unwrap().wb;
let ids_of = |sh: &Sheet| -> Vec<u32> {
sh.charts
.iter()
.map(|c| c.id)
.chain(sh.images.iter().map(|c| c.id))
.chain(sh.shapes.iter().map(|c| c.id))
.chain(sh.slicers.iter().map(|c| c.id))
.collect()
};
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
assert_eq!(ids.len(), 2 * objs.len());
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
let copy = sheet(&s, 1);
for (o, id) in objs.iter().zip(ids_of(copy)) {
assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind));
}
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
}
#[test]
fn bubble_sizes_follow_edits_like_the_other_series_references() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 3, 10], [2, 5, 20], [3, 2, 30]]})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:B3", "type": "bubble"})).unwrap(), "chart");
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().series[0]
.bubble_sizes = Some("Sheet1!$C$1:$C$3".into());
let sizes = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).and_then(|c| c.series[0].bubble_sizes.clone()).unwrap_or_default();
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(sizes(&s), "Sheet1!$C$2:$C$4", "an inserted row moves the bubble sizes with their cells");
s.execute("edit.cut", json!({"range": "C2:C4"})).unwrap();
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
assert_eq!(sizes(&s), "Sheet1!$F$2:$F$4", "a cut-move takes the bubble sizes with the cells");
s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap();
assert_eq!(sizes(&s), "Data!$F$2:$F$4", "a renamed sheet is renamed in the bubble sizes");
}
/// A file can carry an object with the largest id. New objects and a copied sheet's objects then
/// take free ids instead of the largest one again, so `{kind, id}` still names one object.
#[test]
fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
let mut s = s();
let first = id_of(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "B2"})).unwrap(), "shape");
// The file's shape carries the largest id (set on the model, as a loaded file would).
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().shapes.iter_mut().find(|o| o.id == first).unwrap().id = u32::MAX;
let second = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "D2"})).unwrap(), "shape");
let third = id_of(&s.execute("insert.shape", json!({"kind": "triangle", "at": "F2"})).unwrap(), "shape");
assert!(second != u32::MAX && third != u32::MAX && second != third, "new shapes take free ids, not the largest again: {second}, {third}");
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &s.doc().unwrap().wb;
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|o| o.id)).collect();
assert_eq!(ids.len(), 6);
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
}
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
/// within the limit is still capped there.
#[test]
fn an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize() {
let mut s = s();
let image = super::add_image(&mut s, "B2", AnchorMode::MoveOnly);
// The file's picture is 12,000 by 300: set on the model, then written and read back as a file.
{
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(ObjRef::new(ObjKind::Image, image)).unwrap();
a.width = 12_000.0;
a.height = 300.0;
}
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
s.execute("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
let o = ObjRef::new(ObjKind::Image, sheet(&s, 0).images[0].id);
let size = |s: &Session| {
let a = sheet(s, 0).object_anchor(o).unwrap();
(a.width.round(), a.height.round())
};
assert_eq!(size(&s), (12_000.0, 300.0), "the reader keeps the file's size");
s.execute("object.move", json!({"kind": "image", "id": o.id, "at": "D5", "dx": 3, "dy": 4})).unwrap();
let a = sheet(&s, 0).object_anchor(o).unwrap();
assert_eq!((a.cell, a.dx, a.dy), (at("D5"), 3.0, 4.0));
assert_eq!(size(&s), (12_000.0, 300.0), "a move without a size keeps the file's size");
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 12_010})).unwrap();
assert_eq!(size(&s), (12_000.0, 300.0), "the object does not grow past what the file gave");
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 11_990})).unwrap();
assert_eq!(size(&s), (11_990.0, 300.0), "a resize shrinks by what it asks, with no jump to 10,000");
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 50_000, "height": 50_000})).unwrap();
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped: at the current width, and at 10,000 for a height the file kept within it");
}
/// `chart.set` follows the same rule: the Format Chart pane's Size fields send the unchanged
/// dimension back with the edited one, so a chart a file made 12,000 wide keeps that width when
/// its height is edited, shrinks by what is asked and never grows further.
#[test]
fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A3", "type": "column", "at": "C2"})).unwrap(), "chart");
// The file's chart is 12,000 wide (set on the model, as a loaded file would).
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().anchor.width = 12_000.0;
let size = |s: &Session| {
let c = sheet(s, 0).charts.iter().find(|c| c.id == chart).unwrap();
(c.anchor.width, c.anchor.height)
};
s.execute("chart.set", json!({"chart": chart, "width": 12_000, "height": 250})).unwrap();
assert_eq!(size(&s), (12_000.0, 250.0), "the unchanged width sent back with a new height stays");
s.execute("chart.set", json!({"chart": chart, "width": 11_990})).unwrap();
assert_eq!(size(&s), (11_990.0, 250.0), "a resize shrinks by what it asks");
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
}
+43
View File
@@ -646,6 +646,49 @@ pub struct Shape {
pub format: ShapeFormat,
}
/// The kinds of floating object a sheet holds. Commands name an object by kind and
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ObjKind {
Chart,
Image,
Shape,
Slicer,
}
impl ObjKind {
pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer];
/// The name used in command parameters.
pub fn name(self) -> &'static str {
match self {
ObjKind::Chart => "chart",
ObjKind::Image => "image",
ObjKind::Shape => "shape",
ObjKind::Slicer => "slicer",
}
}
/// The kind named `s` (any letter case).
pub fn parse(s: &str) -> Option<ObjKind> {
ObjKind::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
}
}
/// One floating object on a sheet: its kind and its workbook-unique id.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ObjRef {
pub kind: ObjKind,
pub id: u32,
}
impl ObjRef {
pub fn new(kind: ObjKind, id: u32) -> ObjRef {
ObjRef { kind, id }
}
}
/// Horizontal alignment of a shape's text (`a:pPr algn`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShapeTextAlign {
+165 -2
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
Validation,
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, SheetProtection,
Slicer, Sparkline, Table, Validation,
};
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -238,6 +238,81 @@ impl Sheet {
}
}
/// The floating object with workbook-unique id `id` on this sheet.
pub fn object_ref(&self, id: u32) -> Option<ObjRef> {
let kind = if self.charts.iter().any(|o| o.id == id) {
ObjKind::Chart
} else if self.images.iter().any(|o| o.id == id) {
ObjKind::Image
} else if self.shapes.iter().any(|o| o.id == id) {
ObjKind::Shape
} else if self.slicers.iter().any(|o| o.id == id) {
ObjKind::Slicer
} else {
return None;
};
Some(ObjRef::new(kind, id))
}
/// Whether the floating object `o` exists on this sheet.
pub fn has_object(&self, o: ObjRef) -> bool {
self.object_anchor(o).is_some()
}
/// The anchor of the floating object `o`.
pub fn object_anchor(&self, o: ObjRef) -> Option<Anchor> {
match o.kind {
ObjKind::Chart => self.charts.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
}
}
pub fn object_anchor_mut(&mut self, o: ObjRef) -> Option<&mut Anchor> {
match o.kind {
ObjKind::Chart => self.charts.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
}
}
/// Removes the floating object `o`; false when there is no such object.
pub fn remove_object(&mut self, o: ObjRef) -> bool {
fn take<T>(v: &mut Vec<T>, f: impl Fn(&T) -> bool) -> bool {
let n = v.len();
v.retain(|x| !f(x));
v.len() != n
}
match o.kind {
ObjKind::Chart => take(&mut self.charts, |c| c.id == o.id),
ObjKind::Image => take(&mut self.images, |c| c.id == o.id),
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
}
}
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that
/// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones
/// and write them back in step.
pub fn object_anchors(&self) -> impl Iterator<Item = &Anchor> {
self.charts
.iter()
.map(|o| &o.anchor)
.chain(self.images.iter().map(|o| &o.anchor))
.chain(self.shapes.iter().map(|o| &o.anchor))
.chain(self.slicers.iter().map(|o| &o.anchor))
}
pub fn object_anchors_mut(&mut self) -> impl Iterator<Item = &mut Anchor> {
self.charts
.iter_mut()
.map(|o| &mut o.anchor)
.chain(self.images.iter_mut().map(|o| &mut o.anchor))
.chain(self.shapes.iter_mut().map(|o| &mut o.anchor))
.chain(self.slicers.iter_mut().map(|o| &mut o.anchor))
}
/// Displayed value of a cell: its own value, or a spilled value.
pub fn value(&self, c: CellRef) -> Value {
match self.cells.get(c) {
@@ -662,6 +737,94 @@ mod tests {
assert_eq!(s.col_at(-5.0), 0);
assert_eq!(s.col_left(2), 128.0);
}
use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind};
/// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on
/// purpose: a new kind of floating object does not compile here until this test covers it,
/// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order`
/// then catch an accessor that was not taught about it.
fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) {
match kind {
ObjKind::Chart => s.charts.push(Chart {
id,
kind: ChartKind::default(),
anchor,
title: None,
series: vec![],
legend: LegendPos::default(),
data_labels: false,
data_table: None,
gridlines: false,
style: 0,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: None,
}),
ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }),
ObjKind::Shape => s.shapes.push(Shape {
id,
kind: ShapeKind::default(),
anchor,
fill: Color::default(),
line: Color::default(),
text: String::new(),
format: ShapeFormat::default(),
}),
ObjKind::Slicer => {
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
}
}
}
/// Sorting, and inserting or deleting rows and columns, read every anchor through
/// `object_anchors`, compute the new ones and write them back through `object_anchors_mut`
/// in step. The two must visit the same objects in the same order, or an object receives
/// another object's anchor; a kind that reaches only one of them would break that silently.
#[test]
fn object_anchor_accessors_visit_the_same_objects_in_the_same_order() {
let mut s = Sheet::new("S");
// Two objects of every kind, inserted in a mixed order, with ids unrelated to the kind.
// Each anchor cell's row carries the id, so an anchor read back names its object.
let inserted = [
(ObjKind::Slicer, 7),
(ObjKind::Chart, 3),
(ObjKind::Image, 11),
(ObjKind::Shape, 2),
(ObjKind::Chart, 9),
(ObjKind::Slicer, 1),
(ObjKind::Image, 4),
(ObjKind::Shape, 12),
];
for &(kind, id) in &inserted {
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
}
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
assert_eq!(per_kind, inserted.len());
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
// Mark each anchor with its position through the mutable accessor; the shared accessor
// reads the marks back in the same sequence.
for (i, a) in s.object_anchors_mut().enumerate() {
a.dx = i as f32;
}
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
// order) and every object appears in it once.
let documented: Vec<u32> =
ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect();
let visited: Vec<u32> = s.object_anchors().map(|a| a.cell.row).collect();
assert_eq!(visited, documented);
// The mark written through the bulk accessor landed on the object `object_anchor` finds
// by kind and id.
for &(kind, id) in &inserted {
let position = documented.iter().position(|&x| x == id).map(|p| p as f32);
assert_eq!(s.object_anchor(ObjRef::new(kind, id)).map(|a| a.dx), position, "{kind:?} {id}");
}
}
}
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
+95 -18
View File
@@ -463,26 +463,54 @@ impl Workbook {
}
None
}
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
pub fn next_object_id(&self) -> u32 {
let mut m = 0;
for s in &self.sheets {
for c in &s.charts {
m = m.max(c.id);
}
for c in &s.images {
m = m.max(c.id);
}
for c in &s.shapes {
m = m.max(c.id);
}
for c in &s.tables {
m = m.max(c.id);
}
for c in &s.slicers {
m = m.max(c.id);
}
self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
}
/// `count` distinct ids no object in the workbook uses, sorted: the ones above the largest id
/// in use while they last, then the lowest free ones. Fewer come back only when the id space
/// is exhausted.
pub fn free_object_ids(&self, count: usize) -> Vec<u32> {
let mut used: Vec<u32> = self
.sheets
.iter()
.flat_map(|s| {
s.charts
.iter()
.map(|o| o.id)
.chain(s.images.iter().map(|o| o.id))
.chain(s.shapes.iter().map(|o| o.id))
.chain(s.tables.iter().map(|o| o.id))
.chain(s.slicers.iter().map(|o| o.id))
})
.collect();
used.sort_unstable();
used.dedup();
let max = used.last().copied().unwrap_or(0);
let mut out = Vec::with_capacity(count.min(1 << 16));
let mut next = max.checked_add(1);
while out.len() < count
&& let Some(id) = next
{
out.push(id);
next = id.checked_add(1);
}
m + 1
// Past the top: the gaps below the largest id, from 1 (0 is never an object's id).
let mut taken = used.iter().copied().peekable();
let mut id = 1u32;
while out.len() < count && id < max {
while taken.next_if(|&t| t < id).is_some() {}
if taken.peek() == Some(&id) {
taken.next();
} else {
out.push(id);
}
id += 1;
}
out.sort_unstable();
out
}
}
@@ -532,4 +560,53 @@ mod tests {
assert!(wb.check_sheet_name(&"x".repeat(32), None).is_err());
assert!(wb.check_sheet_name("Budget 2026", None).is_ok());
}
#[test]
fn object_ids_never_overflow_or_repeat() {
let mut wb = Workbook::new();
let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
assert_eq!(wb.next_object_id(), 1);
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(7));
}
assert_eq!(wb.next_object_id(), 8);
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(u32::MAX));
}
// An id read from a file at the maximum: the lowest free id, not the maximum again.
assert_eq!(wb.next_object_id(), 1);
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(1));
sh.images.push(image(3));
}
assert_eq!(wb.next_object_id(), 2);
assert_eq!(wb.free_object_ids(4), vec![2, 4, 5, 6], "the gaps, skipping 1, 3 and 7");
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(u32::MAX - 1));
}
assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
}
/// A file can use the largest id and the one below it: nothing is free above them, so the free
/// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
/// served in time linear in the count and the ids in use.
#[test]
fn free_object_ids_come_from_below_when_nothing_is_free_above() {
let mut wb = Workbook::new();
let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(u32::MAX));
sh.images.push(image(u32::MAX - 1));
sh.images.push(image(0));
}
assert_eq!(wb.free_object_ids(3), vec![1, 2, 3]);
assert_eq!(wb.next_object_id(), 1);
// The 100,000 lowest free ids: distinct, ascending, none of them in use.
let many = wb.free_object_ids(100_000);
assert_eq!(many.len(), 100_000);
assert!(many.windows(2).all(|w| w[0] < w[1]), "ascending without repeats");
assert!(!many.contains(&0) && !many.contains(&(u32::MAX - 1)) && !many.contains(&u32::MAX));
assert_eq!(wb.free_object_ids(0), Vec::<u32>::new());
}
}
+4 -3
View File
@@ -2,7 +2,7 @@
//!
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr]
//! [--dialog 'name={json}'] [--hf-edit header|footer] out.png`
//! [--pane name] [--object id] [--dialog 'name={json}'] [--hf-edit header|footer] out.png`
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
@@ -59,7 +59,8 @@ fn main() {
i += 1;
pane = args.get(i).cloned();
}
"--chart" => {
// Selects a floating object of any kind by id (`--chart` is the older spelling).
"--object" | "--chart" => {
i += 1;
chart = args.get(i).and_then(|s| s.parse().ok());
}
@@ -115,7 +116,7 @@ fn main() {
app.ui.ribbon_tab = t;
}
app.grid.pane = pane;
app.selected_chart = chart;
app.select_object_id(chart);
if let Some(d) = &dialog {
let (name, p) =
d.split_once('=').map(|(a, b)| (a, serde_json::from_str(b).unwrap_or(json!({})))).unwrap_or((d.as_str(), json!({})));
+1 -1
View File
@@ -68,7 +68,7 @@ impl SheetApp {
capture_epoch: 0,
capture_geometry: None,
capture_geometry_from_raw: false,
selected_chart: None,
selected_object: None,
clipboard_text_at_copy: None,
cut_text: None,
tab_run: None,
+2 -1
View File
@@ -89,7 +89,7 @@ impl SheetApp {
&& let Ok(r) = &r
&& let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok())
{
self.selected_chart = Some(chart);
self.selected_object = Some(gridcraft_engine::model::ObjRef::new(gridcraft_engine::model::ObjKind::Chart, chart));
}
if id == "file.exportPdf"
&& let Ok(r) = &r
@@ -271,6 +271,7 @@ impl SheetApp {
pub fn after_engine(&mut self) {
// The requests belong to the window the command left active.
self.follow_active_window();
self.prune_selected_object();
for req in self.session.take_ui_requests() {
match req {
UiRequest::Dialog(name, params) => self.open_dialog(&name, params),
+47 -30
View File
@@ -1,10 +1,10 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures and shapes.
//! we paint with egui), pictures, shapes and slicers.
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_chart::{HAlign, Prim, VAlign};
use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook};
use gridcraft_engine::model::{Anchor, ObjKind, ObjRef, ShapeKind, Sheet, Workbook};
use crate::SheetApp;
use crate::grid::Geo;
@@ -265,7 +265,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
return painted;
}
// Source outlines stay in cell space beneath every floating object.
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id))
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart() == Some(ch.id))
&& anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect())
{
for s in &ch.series {
@@ -345,9 +345,6 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) {
paint_shape_text(p, wb, sp, r, geo.z);
}
if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
selection_frame(p, r);
}
done(start, r);
}
for im in &sh.images {
@@ -361,9 +358,6 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
} else {
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track);
}
if app.selected_chart == Some(im.id) {
selection_frame(p, r);
}
done(start, r);
}
for ch in &sh.charts {
@@ -379,12 +373,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
let m = EguiMeasure { painter: p, z: geo.z };
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
if app.selected_chart == Some(ch.id) {
selection_frame(p, r);
}
done(start, r);
}
paint_slicers(app, p, geo, wb, si, sh);
paint_slicers(p, geo, wb, si, sh);
// The selected object's frame and handles go over every object, and move with it on a
// right-to-left sheet.
if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o))
&& r.intersects(p.clip_rect())
{
let start = p.add(egui::Shape::Noop);
selection_frame(p, r);
done(start, r);
}
painted
}
@@ -421,7 +421,7 @@ pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout
SlicerLayout { header, clear, tiles }
}
fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
fn paint_slicers(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
let tk = theme::Tokens::get(p.ctx());
for sl in &sh.slicers {
let r = anchor_rect(geo, sh, &sl.anchor);
@@ -455,9 +455,6 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
}
if app.selected_chart == Some(sl.id) {
selection_frame(p, r);
}
}
}
@@ -496,40 +493,60 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) ->
fn selection_frame(p: &Painter, r: Rect) {
let t = Tokens::get(p.ctx());
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
for h in crate::objects::Handle::ALL {
let c = h.pos(r);
p.circle_filled(c, 4.0, t.sheet_control_bg);
p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border));
}
let _ = Align2::CENTER_CENTER;
}
/// The topmost object under `p`: (kind, id, rect).
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> {
if app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
/// Where object `o` sits on the sheet (logical coordinates), when it exists on `sh`.
pub fn object_rect(geo: &Geo, sh: &Sheet, o: ObjRef) -> Option<Rect> {
sh.object_anchor(o).map(|a| anchor_rect(geo, sh, &a))
}
/// The resize handle of the selected object under `p`, with the object and its rect. Handles
/// reach a little outside their object, so they win over [`hit`], but like it only over the cell
/// area: a handle that lies under the headers, the formula bar or a scroll bar leaves the press
/// to them. Nothing while objects are hidden.
pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, crate::objects::Handle)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
let o = app.selected_object?;
let r = object_rect(geo, sh, o)?;
crate::objects::handle_at(r, p).map(|h| (o, r, h))
}
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell
/// area or while objects are hidden.
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) && geo.cells.contains(p) {
return Some(("slicer", sl.id, r));
if r.contains(p) {
return Some((ObjRef::new(ObjKind::Slicer, sl.id), r));
}
}
for ch in sh.charts.iter().rev() {
let r = anchor_rect(geo, sh, &ch.anchor);
if r.expand(4.0).contains(p) && geo.cells.contains(p) {
return Some(("chart", ch.id, r));
if r.expand(4.0).contains(p) {
return Some((ObjRef::new(ObjKind::Chart, ch.id), r));
}
}
for im in sh.images.iter().rev() {
let r = anchor_rect(geo, sh, &im.anchor);
if r.contains(p) && geo.cells.contains(p) {
return Some(("image", im.id, r));
if r.contains(p) {
return Some((ObjRef::new(ObjKind::Image, im.id), r));
}
}
for s in sh.shapes.iter().rev() {
let r = anchor_rect(geo, sh, &s.anchor);
if r.expand(3.0).contains(p) && geo.cells.contains(p) {
return Some(("shape", s.id, r));
if r.expand(3.0).contains(p) {
return Some((ObjRef::new(ObjKind::Shape, s.id), r));
}
}
None
+29 -2
View File
@@ -4,7 +4,8 @@
//! - `engine.execute {command, params}` — run any engine or UI command
//! - `engine.commands {search?}` — every command with enablement and params docs
//! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, grid geometry, perf)
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, selected object, grid geometry, perf)
//! - `ui.object.select {id?, kind?}` — select a floating object; replies with its rect
//! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`,
//! `ui.move {x, y}` — real pointer input in window points
//! - `ui.cell.click {cell}`, `ui.cell.drag {from, to}` — pointer input on a cell's centre
@@ -149,7 +150,9 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
"selectedChart": app.selected_chart,
"selectedObject": app.selected_object,
// The id when the selection is a chart, as before `selectedObject`.
"selectedChart": app.selected_chart(),
"mode": format!("{:?}", app.session.mode),
"documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
@@ -159,6 +162,29 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
})
}
/// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind
/// is found from the id when left out, and must match when given); no `id` clears the selection.
/// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`.
fn select_object(app: &mut SheetApp, p: &Json) -> Outcome {
use gridcraft_engine::model::{ObjKind, ObjRef};
let Some(id) = p.get("id").and_then(Json::as_u64) else {
app.selected_object = None;
return ok(Json::Null);
};
let Some(sh) = app.session.active().and_then(|d| d.wb.active()) else { return err("no document") };
let found = u32::try_from(id).ok().and_then(|id| match p.get("kind").and_then(Json::as_str) {
Some(k) => ObjKind::parse(k).map(|k| ObjRef::new(k, id)).filter(|o| sh.has_object(*o)),
None => sh.object_ref(id),
});
let Some(o) = found else { return err("no such object on the active sheet") };
let rect = app.grid.rect.and_then(|r| {
let geo = crate::grid::Geo::new(sh, r, app.view().scroll);
crate::chartview::object_rect(&geo, sh, o).map(|r| geo.mirror_rect(r))
});
app.selected_object = Some(o);
ok(json!({"selected": o, "rect": rect.map(|r| [r.left(), r.top(), r.width(), r.height()])}))
}
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
fn filter_commands(commands: Vec<Json>, search: Option<&str>) -> Vec<Json> {
let Some(q) = search.map(str::to_ascii_lowercase) else { return commands };
@@ -292,6 +318,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
app.dialog = None;
ok(Json::Null)
}
"ui.object.select" => select_object(app, p),
"ui.message.dismiss" => {
app.message = None;
ok(Json::Null)
+37 -7
View File
@@ -632,12 +632,25 @@ pub(super) fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial
}
// 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());
// Integer models, the Evolutionary method and Value Of goals have no Sensitivity report, and
// it describes an optimum, so a run that stopped before one (at the evaluation limit) has
// none either.
let model_allows = d.values.get("model").is_none_or(gridcraft_engine::cmd::solver::sensitivity_applies);
let optimum = d.values.get("status").and_then(Json::as_str).is_some_and(gridcraft_engine::cmd::solver::sensitivity_status);
let sensitivity = model_allows && optimum;
let why_not = if model_allows {
"Needs an optimal or converged solution: Solver stopped before reaching one."
} else {
"Needs Simplex LP or GRG Nonlinear, a Max or Min objective and no integer constraints."
};
let offered = |key: &str| key != "sensitivityReport" || sensitivity;
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() {
for (key, label) in [("answerReport", "Answer"), ("sensitivityReport", "Sensitivity"), ("limitsReport", "Limits")] {
let mut on = offered(key) && d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
let check = ui.add_enabled(offered(key), egui::Checkbox::new(&mut on, label)).on_disabled_hover_text(why_not);
if check.changed() {
d.values.insert(key.into(), json!(on));
}
}
@@ -649,15 +662,32 @@ pub(super) fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial
if changed && restore {
app.run_or_alert("edit.undo", json!({}));
}
let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")]
let reports: Vec<&str> = [("answerReport", "answer"), ("sensitivityReport", "sensitivity"), ("limitsReport", "limits")]
.into_iter()
.filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true))
.filter(|(key, _)| offered(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);
// A Sensitivity report the values turn out not to support is left out, and the other
// reports are still written.
let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"),
"message": field("message"), "skipNotOptimal": true});
match app.run("data.solverReport", params) {
Ok(r) => {
let reasons: Vec<&str> = r
.get("skippedReports")
.and_then(Json::as_array)
.into_iter()
.flatten()
.filter_map(|k| k.get("reason").and_then(Json::as_str))
.collect();
if !reasons.is_empty() {
app.message = Some(("Solver Results".into(), reasons.join("\n")));
}
}
Err(e) => app.message = Some(("Solver Results".into(), crate::clean_error(&e))),
}
}
*open = false;
}
+1 -1
View File
@@ -114,7 +114,7 @@ pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut
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());
app.select_object_id(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))),
+6 -3
View File
@@ -131,12 +131,15 @@ pub enum Drag {
grab: CellRef,
to: Option<CellRef>,
},
/// Moving a floating object, or resizing it from one of its eight handles.
Object {
kind: &'static str,
id: u32,
obj: gridcraft_engine::model::ObjRef,
start: Pos2,
orig: (CellRef, f32, f32, f32, f32),
resize: bool,
handle: Option<crate::objects::Handle>,
/// This drag has made its undo step; later frames merge into it, and never into the
/// step of an earlier drag.
stepped: bool,
},
}
+15 -10
View File
@@ -1,5 +1,5 @@
use super::geo::Geo;
use super::interact::{OBJECT_RESIZE_RADIUS, col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2};
/// Decide after interaction so drag start and release use the new state in the same frame.
@@ -30,7 +30,7 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
// has Drag::None and uses hover. A click can briefly leave a drag state after release.
if resp.dragged()
&& pointer.primary_down()
&& let Some(cursor) = drag_cursor(&app.grid.drag)
&& let Some(cursor) = drag_cursor(&app.grid.drag, geo.rtl)
{
ctx.set_cursor_icon(cursor);
return;
@@ -42,18 +42,19 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
hover_cursor(app, ctx, resp, &pointer, geo, sh, p);
}
/// The cursor a held drag shows, or `None` when the drag leaves it to hover.
fn drag_cursor(drag: &Drag) -> Option<CursorIcon> {
/// The cursor a held drag shows, or `None` when the drag leaves it to hover. `rtl` swaps the
/// diagonal resize cursors of an object's corner handles on a mirrored sheet.
fn drag_cursor(drag: &Drag, rtl: bool) -> Option<CursorIcon> {
match drag {
Drag::None => None,
Drag::Select => Some(CursorIcon::Cell),
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move),
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
Drag::Object { handle: Some(h), .. } => Some(h.cursor(rtl)),
}
}
@@ -75,10 +76,14 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin
let border_panes = geo.range_panes(sel.current());
let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p));
let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id));
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
if app.grid.drag == Drag::None && near_corner {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
// The selected object's handles resize it; any object's body moves it.
let idle = app.grid.drag == Drag::None && app.editor.is_none();
let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
if let Some(h) = object_handle {
ctx.set_cursor_icon(h.cursor(geo.rtl));
} else if on_object {
ctx.set_cursor_icon(CursorIcon::Move);
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge);
} else if on_handle && app.editor.is_none() {
+62 -62
View File
@@ -1,3 +1,5 @@
use gridcraft_engine::model::{ObjKind, ObjRef};
use super::editor::validation_arrow;
use super::geo::Geo;
use super::keyboard::keyboard;
@@ -11,9 +13,6 @@ use super::{
// ------------------------------------------------------------------ interaction
/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it.
pub(super) const OBJECT_RESIZE_RADIUS: f32 = 10.0;
struct InteractionInput<'a> {
ui: &'a mut egui::Ui,
response: &'a egui::Response,
@@ -107,7 +106,7 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
app.selected_chart = Some(id);
app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id));
match act {
crate::chartview::SlicerAction::Toggle(value) => {
let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl}));
@@ -123,41 +122,39 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
let primary_drag = resp.drag_started_by(egui::PointerButton::Primary);
if primary_drag || primary_clicked {
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
let at = if primary_drag { pointer.press_origin().or(pos) } else { pos };
let at = if primary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos };
// The selected object's handles reach a little outside it, so they win over hit-testing.
// Only they resize: a press anywhere on another object selects and moves it, as the
// hover cursor shows.
let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p));
let handle = on_handle.map(|(.., h)| h);
if let Some(p) = at
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
&& let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
{
app.selected_chart = Some(id);
if kind == "shape"
&& resp.double_clicked()
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox)
{
app.begin_text_box_edit(id);
app.selected_object = Some(obj);
if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select {
return;
}
if kind == "chart" && resp.double_clicked() {
app.grid.pane = Some("formatChart".into());
}
if primary_drag {
let a = object_anchor(sh, kind, id);
let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS;
if let Some(a) = a {
app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize };
if primary_drag && let Some(a) = sh.object_anchor(obj) {
app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle, stepped: false };
// The first recognised drag frame already holds movement away from the press.
if let Some(now) = pos {
drag_object(app, geo, sh, now);
}
}
return;
}
app.selected_chart = None;
app.selected_object = None;
}
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
// under it; a right press that wobbles into a drag counts too (#2067).
let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary);
if (resp.secondary_clicked() || secondary_drag)
&& let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }
&& let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p)
&& let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_chart = Some(id);
app.grid.object_menu = Some((geo.mirror_pos(p), kind, id));
app.selected_object = Some(obj);
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
return;
}
@@ -241,7 +238,7 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
}
if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) {
if let Some(p) = pos
&& let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p)
&& let Some((ObjRef { kind: ObjKind::Shape, id }, _)) = crate::chartview::hit(app, geo, sh, p)
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink)
{
let _ = app.run("object.delete", json!({"kind": "shape", "id": id}));
@@ -669,21 +666,7 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) };
scroll_by(app, auto);
}
Drag::Object { kind, id, start, orig, resize } => {
let d = (p - start) / geo.z;
let (cell, dx, dy, w, h) = orig;
let params = if resize {
json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)})
} else {
// Re-anchor at the cell under the new top-left.
let x0 = sh.col_left(cell.col) as f32 + dx + d.x;
let y0 = sh.row_top(cell.row) as f32 + dy + d.y;
let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64));
json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32})
};
let _ = app.session.run("object.move", params);
coalesce_last_undo(app);
}
Drag::Object { .. } => drag_object(app, geo, sh, p),
Drag::None => {}
}
}
@@ -974,29 +957,46 @@ pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<u32> {
row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0)
}
fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::model::Anchor> {
match kind {
"chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor),
"image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor),
"slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor),
_ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor),
}
}
pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
let Some(id) = app.selected_chart else { return false };
let kind = if sh.charts.iter().any(|object| object.id == id) {
"chart"
} else if sh.images.iter().any(|object| object.id == id) {
"image"
} else if sh.shapes.iter().any(|object| object.id == id) {
"shape"
} else {
return false;
/// Moves the dragged object, or resizes it from the grabbed handle, to follow the pointer at `p`
/// (logical coordinates). The whole drag coalesces into one undo step of its own.
fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) {
let Drag::Object { obj, start, orig, handle, stepped } = app.grid.drag.clone() else { return };
let d = (p - start) / geo.z;
let (cell, dx, dy, w, h) = orig;
let x0 = sh.col_left(cell.col) as f32 + dx;
let y0 = sh.row_top(cell.row) as f32 + dy;
let [x0, y0, x1, y1] = match handle {
Some(hd) => crate::objects::resized([x0, y0, x0 + w, y0 + h], hd, d, crate::objects::min_size(sh, obj)),
None => {
let (x0, y0) = ((x0 + d.x).max(0.0), (y0 + d.y).max(0.0));
[x0, y0, x0 + w, y0 + h]
}
};
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_chart = None;
true
// Re-anchor at the cell under the new top-left.
let nc = CellRef::new(sh.row_at(y0 as f64), sh.col_at(x0 as f64));
let mut params = json!({
"kind": obj.kind.name(), "id": obj.id, "at": nc.a1(),
"dx": x0 - sh.col_left(nc.col) as f32, "dy": y0 - sh.row_top(nc.row) as f32,
});
// Only a handle changes the size. A body move sends none, so the engine keeps whatever the
// object has: a file can make it larger than `object.move` accepts for a new size, and
// moving it must not shrink it to that limit.
if handle.is_some()
&& let Some(p) = params.as_object_mut()
{
p.insert("width".into(), json!(x1 - x0));
p.insert("height".into(), json!(y1 - y0));
}
let revision = app.session.active().map(|d| d.revision);
let _ = app.session.run("object.move", params);
if stepped {
coalesce_last_undo(app);
} else if app.session.active().map(|d| d.revision) != revision
&& let Drag::Object { stepped, .. } = &mut app.grid.drag
{
// The first step of this drag stays apart from an earlier drag's step of the same name.
*stepped = true;
}
}
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
+4 -3
View File
@@ -1,5 +1,4 @@
use super::geo::Geo;
use super::interact::delete_selected_object;
use super::menus::is_checkbox;
use super::{CellRef, Drag, Sheet, SheetApp, json};
@@ -124,9 +123,10 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }),
Key::F2 => app.begin_edit(None, false),
Key::Delete if delete_selected_object(app, sh) => {}
// With an object selected, Delete and Backspace delete it, not the cells.
Key::Delete if app.delete_selected_object() => {}
Key::Delete => app.run_or_alert("edit.clearContents", json!({})),
Key::Backspace if delete_selected_object(app, sh) => {}
Key::Backspace if app.delete_selected_object() => {}
Key::Backspace => {
// Excel for Mac: Delete clears the cell and starts editing it.
app.run_or_alert("edit.clearContents", json!({}));
@@ -134,6 +134,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
app.begin_edit(Some(String::new()), false);
}
}
Key::Escape if app.selected_object.is_some() => app.selected_object = None,
Key::Escape => {
app.session.clipboard = None;
app.session.format_painter = None;
+1 -1
View File
@@ -393,7 +393,7 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
"ui:formatChart" => app.grid.pane = Some("formatChart".into()),
_ => {
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_chart = None;
app.selected_object = None;
}
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
// Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act
// on something the user can't see.
if crate::chartview::objects_hidden(&wb) {
app.selected_chart = None;
app.selected_object = None;
}
let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
paint_overlays(OverlayInput {
+3 -2
View File
@@ -40,6 +40,7 @@ pub mod keymap;
pub mod keytips;
pub mod macro_editor;
pub mod macro_ui;
pub mod objects;
pub mod outline;
pub mod panes;
pub mod paper_view;
@@ -266,8 +267,8 @@ pub struct SheetApp {
/// false (a bare test/embedder that never runs the hook) capture code falls back to egui's
/// current input instead of reusing a stale recorded value.
pub(crate) capture_geometry_from_raw: bool,
/// Chart selected on the sheet (id).
pub selected_chart: Option<u32>,
/// The floating object selected on the sheet (chart, picture, shape or slicer).
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
/// The system clipboard's text when GridCraft last copied without publishing to it (a
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
/// text, GridCraft's copy is the newer one and Paste uses it.
+217
View File
@@ -0,0 +1,217 @@
//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes and
//! slicers). Painting and hit-testing live in `chartview`.
use egui::{CursorIcon, Pos2, Rect, Vec2};
use gridcraft_engine::model::{ObjKind, ObjRef, ShapeKind, Sheet};
use serde_json::json;
use crate::SheetApp;
/// One of the eight resize handles on a selected object's frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Handle {
TopLeft,
Top,
TopRight,
Left,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Handle {
pub const ALL: [Handle; 8] =
[Handle::TopLeft, Handle::Top, Handle::TopRight, Handle::Left, Handle::Right, Handle::BottomLeft, Handle::Bottom, Handle::BottomRight];
/// Which edges the handle moves: -1 the left or top edge, 1 the right or bottom edge, 0 neither.
pub fn sides(self) -> (i8, i8) {
match self {
Handle::TopLeft => (-1, -1),
Handle::Top => (0, -1),
Handle::TopRight => (1, -1),
Handle::Left => (-1, 0),
Handle::Right => (1, 0),
Handle::BottomLeft => (-1, 1),
Handle::Bottom => (0, 1),
Handle::BottomRight => (1, 1),
}
}
/// Where the handle sits on `r`.
pub fn pos(self, r: Rect) -> Pos2 {
let (sx, sy) = self.sides();
let x = match sx {
-1 => r.left(),
1 => r.right(),
_ => r.center().x,
};
let y = match sy {
-1 => r.top(),
1 => r.bottom(),
_ => r.center().y,
};
Pos2::new(x, y)
}
/// The pointer shape over the handle (on a right-to-left sheet the diagonals swap, as the
/// frame is mirrored on screen).
pub fn cursor(self, rtl: bool) -> CursorIcon {
match self {
Handle::TopLeft | Handle::BottomRight if !rtl => CursorIcon::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft if rtl => CursorIcon::ResizeNwSe,
Handle::TopLeft | Handle::BottomRight | Handle::TopRight | Handle::BottomLeft => CursorIcon::ResizeNeSw,
Handle::Left | Handle::Right => CursorIcon::ResizeHorizontal,
Handle::Top | Handle::Bottom => CursorIcon::ResizeVertical,
}
}
}
/// The handle of `r` under `p`: the nearest one within reach. Corners reach further than edge
/// midpoints, and the reach shrinks on small objects so that their middle still moves them.
pub fn handle_at(r: Rect, p: Pos2) -> Option<Handle> {
let small = r.width().min(r.height());
Handle::ALL
.into_iter()
.map(|h| {
let (sx, sy) = h.sides();
let reach = if sx != 0 && sy != 0 { 10.0f32 } else { 7.0 }.min((small / 3.0).max(4.0));
(h, (h.pos(r) - p).length(), reach)
})
.filter(|(_, d, reach)| d <= reach)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(h, ..)| h)
}
/// The smallest size (points) a resize leaves: lines and arrows may lie flat, every other object
/// keeps some body.
pub fn min_size(sh: &Sheet, o: ObjRef) -> Vec2 {
let flat = o.kind == ObjKind::Shape && sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::Line | ShapeKind::Arrow));
if flat { Vec2::new(1.0, 0.0) } else { Vec2::new(8.0, 8.0) }
}
/// The sheet-point rectangle `[x0, y0, x1, y1]` after dragging `handle` by `d` points. Only the
/// grabbed edges move; they never cross the opposite edge (each keeps `min` from it) or leave
/// the sheet's top-left corner.
pub fn resized(orig: [f32; 4], handle: Handle, d: Vec2, min: Vec2) -> [f32; 4] {
let [mut x0, mut y0, mut x1, mut y1] = orig;
let (sx, sy) = handle.sides();
match sx {
-1 => x0 = (x0 + d.x).min(x1 - min.x).max(0.0),
1 => x1 = (x1 + d.x).max(x0 + min.x),
_ => {}
}
match sy {
-1 => y0 = (y0 + d.y).min(y1 - min.y).max(0.0),
1 => y1 = (y1 + d.y).max(y0 + min.y),
_ => {}
}
[x0, y0, x1, y1]
}
/// What double-clicking an object opens.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenAction {
/// Edit a text box's text in place (`text_box.rs`).
EditText(u32),
/// The Format Chart pane.
FormatChart(u32),
/// Nothing beyond selecting it.
Select,
}
pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
match o.kind {
ObjKind::Chart => OpenAction::FormatChart(o.id),
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
}
}
impl SheetApp {
/// Double-click on an object: selects it and opens what edits it.
pub fn open_object(&mut self, o: ObjRef) -> OpenAction {
self.selected_object = Some(o);
let action = self.session.active().and_then(|d| d.wb.active()).map_or(OpenAction::Select, |sh| open_action(sh, o));
match action {
OpenAction::EditText(id) => {
self.begin_text_box_edit(id);
}
OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()),
OpenAction::Select => {}
}
action
}
/// The selected object's id when it is a chart (the Chart Design tab and Format Chart pane).
pub fn selected_chart(&self) -> Option<u32> {
self.selected_object.filter(|o| o.kind == ObjKind::Chart).map(|o| o.id)
}
/// Selects the object with id `id` on the active sheet, whatever its kind; `None` (or an id
/// that is not there) clears the selection.
pub fn select_object_id(&mut self, id: Option<u32>) {
self.selected_object = id.and_then(|id| self.session.active().and_then(|d| d.wb.active()).and_then(|sh| sh.object_ref(id)));
}
/// Deletes the selected object (Delete and Backspace). False when nothing on the active
/// sheet is selected, so the key clears cells instead.
pub fn delete_selected_object(&mut self) -> bool {
let Some(o) = self.selected_object.take() else { return false };
if !self.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.has_object(o)) {
return false;
}
self.run_or_alert("object.delete", json!({"kind": o.kind.name(), "id": o.id}));
true
}
/// Drops the selection when its object is gone from the active sheet (deleted, undone, or
/// another sheet or workbook became active).
pub fn prune_selected_object(&mut self) {
if let Some(o) = self.selected_object
&& !self.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.has_object(o))
{
self.selected_object = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{pos2, vec2};
#[test]
fn handles_sit_on_the_frame_and_corners_win() {
let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(200.0, 100.0));
for h in Handle::ALL {
assert_eq!(handle_at(r, h.pos(r)), Some(h));
}
assert_eq!(handle_at(r, r.center()), None);
assert_eq!(handle_at(r, pos2(106.0, 106.0)), Some(Handle::TopLeft));
// A tiny object keeps its middle for moving.
let tiny = Rect::from_min_size(pos2(0.0, 0.0), vec2(12.0, 12.0));
assert_eq!(handle_at(tiny, tiny.center()), None);
}
#[test]
fn resizing_moves_only_the_grabbed_edges_and_never_inverts() {
let o = [10.0, 20.0, 110.0, 70.0];
let m = vec2(8.0, 8.0);
assert_eq!(resized(o, Handle::BottomRight, vec2(5.0, 6.0), m), [10.0, 20.0, 115.0, 76.0]);
assert_eq!(resized(o, Handle::TopLeft, vec2(5.0, 6.0), m), [15.0, 26.0, 110.0, 70.0]);
assert_eq!(resized(o, Handle::Top, vec2(50.0, 6.0), m), [10.0, 26.0, 110.0, 70.0]);
assert_eq!(resized(o, Handle::Right, vec2(-500.0, 6.0), m), [10.0, 20.0, 18.0, 70.0]);
assert_eq!(resized(o, Handle::Left, vec2(-500.0, 0.0), m), [0.0, 20.0, 110.0, 70.0]);
assert_eq!(resized(o, Handle::BottomLeft, vec2(500.0, 500.0), m), [102.0, 20.0, 110.0, 570.0]);
}
#[test]
fn diagonal_cursors_swap_on_a_mirrored_sheet() {
assert_eq!(Handle::BottomRight.cursor(false), CursorIcon::ResizeNwSe);
assert_eq!(Handle::BottomRight.cursor(true), CursorIcon::ResizeNeSw);
assert_eq!(Handle::TopRight.cursor(false), CursorIcon::ResizeNeSw);
assert_eq!(Handle::TopRight.cursor(true), CursorIcon::ResizeNwSe);
assert_eq!(Handle::Left.cursor(true), CursorIcon::ResizeHorizontal);
}
}
+26 -7
View File
@@ -3,6 +3,8 @@
use egui::{Color32, Ui, vec2};
use serde_json::{Value as Json, json};
use gridcraft_engine::model::{ObjKind, ObjRef};
use crate::SheetApp;
use crate::theme::{self, Tokens};
@@ -146,10 +148,11 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) {
for it in items.iter().rev() {
let kind = it["kind"].as_str().unwrap_or("").to_string();
let id = it["id"].as_u64().unwrap_or(0) as u32;
let obj = ObjKind::parse(&kind).map(|k| ObjRef::new(k, id));
ui.horizontal(|ui| {
let sel = app.selected_chart == Some(id);
let sel = obj.is_some() && app.selected_object == obj;
if ui.selectable_label(sel, it["name"].as_str().unwrap_or("")).clicked() {
app.selected_chart = Some(id);
app.selected_object = obj;
}
if ui.small_button("▲").on_hover_text("Bring Forward").clicked() {
app.run_or_alert("arrange.bringForward", json!({"kind": kind, "id": id}));
@@ -166,7 +169,7 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) {
/// Draws the chart format pane: title, type, legend, elements/axis titles, series colors, palettes/layouts and size.
fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
let Some(id) = app.selected_chart else {
let Some(id) = app.selected_chart() else {
ui.label(egui::RichText::new("Select a chart to format it.").italics());
return;
};
@@ -305,13 +308,29 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
ui.collapsing("Size", |ui| {
// Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away.
let draft = egui::Id::unique(("gridcraft.chart_size_draft", id));
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height));
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0));
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0));
let current = (chart.anchor.width, chart.anchor.height);
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or(current);
// A file can make a chart larger than the 4,000 points a field otherwise reaches. The
// range keeps such a size, so the field shows it instead of clamping it to 4,000.
let range = |v: f32| 40.0..=v.max(4000.0);
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(range(current.0)));
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(range(current.1)));
if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() {
ui.data_mut(|d| d.remove::<(f32, f32)>(draft));
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h}));
// Only an edited dimension is sent: the other keeps what the chart has, however
// large a file made it.
let mut params = serde_json::Map::new();
params.insert("chart".into(), json!(id));
if w != current.0 {
params.insert("width".into(), json!(w));
}
if h != current.1 {
params.insert("height".into(), json!(h));
}
if params.len() > 1 {
app.run_or_alert("chart.set", Json::Object(params));
}
}
} else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, (w, h)));
+1 -1
View File
@@ -282,7 +282,7 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
if sh.table_at(d.selection.active).is_some() {
v.push("Table Design");
}
if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) {
if app.selected_chart().is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) {
v.push("Chart Design");
}
if app.grid.hf_edit.is_some() {
+2 -2
View File
@@ -109,7 +109,7 @@ fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str
}
pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
let Some(id) = app.selected_chart else { return };
let Some(id) = app.selected_chart() else { return };
let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) {
keytip(app, ui, sequence, ae.rect.center_bottom());
@@ -181,6 +181,6 @@ pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
control_keytip(app, ui, &__k, "chart.delete");
if __k.clicked() {
act(app, "chart.delete", json!({"chart": id}));
app.selected_chart = None;
app.selected_object = None;
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ impl SheetApp {
request_focus: true,
rect: None,
});
self.selected_chart = Some(id);
self.selected_object = Some(gridcraft_engine::model::ObjRef::new(gridcraft_engine::model::ObjKind::Shape, id));
self.grid.drag = crate::grid::Drag::None;
self.grid.drag_select = false;
self.session.mode = Mode::Edit;
+3 -3
View File
@@ -23,7 +23,7 @@ pub struct WindowUi {
grid: grid::GridState,
name_box: Option<String>,
keytips: keytips::KeyTips,
selected_chart: Option<u32>,
selected_object: Option<gridcraft_engine::model::ObjRef>,
tab_run: Option<(u64, usize, u32, CellRef)>,
open_switch_windows: bool,
/// The ribbon tab this window shows. A contextual tab depends on this window's selection, so
@@ -65,7 +65,7 @@ impl SheetApp {
grid,
name_box,
keytips,
selected_chart,
selected_object,
tab_run,
open_switch_windows,
ribbon_tab,
@@ -81,7 +81,7 @@ impl SheetApp {
std::mem::swap(&mut self.grid, grid);
std::mem::swap(&mut self.name_box, name_box);
std::mem::swap(&mut self.keytips, keytips);
std::mem::swap(&mut self.selected_chart, selected_chart);
std::mem::swap(&mut self.selected_object, selected_object);
std::mem::swap(&mut self.tab_run, tab_run);
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
+2 -1
View File
@@ -1,6 +1,7 @@
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_engine::model::{ObjKind, ObjRef};
use serde_json::json;
#[test]
@@ -10,7 +11,7 @@ fn format_chart_pane_controls_the_engine_data_table() {
session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
let id = session.execute("insert.chart", json!({"range": "A1:C3", "type": "column", "at": "E3"})).unwrap()["chart"].as_u64().unwrap() as u32;
let mut h = crate::support::harness(session, 4);
h.state_mut().selected_chart = Some(id);
h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Chart, id));
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::DisclosureTriangle, "Data Table").click();
+48 -3
View File
@@ -59,7 +59,7 @@ fn dragging_the_corner_handle_resizes_the_chart() {
h.run_steps(2);
button(&mut h, inside, true);
button(&mut h, inside, false);
assert_eq!(h.state().selected_chart, Some(id));
assert_eq!(h.state().selected_chart(), Some(id));
h.input_mut().events.push(Event::PointerMoved(start));
h.run_steps(2);
@@ -76,7 +76,7 @@ fn dragging_the_corner_handle_resizes_the_chart() {
/// Opens Format Chart Area for the chart and expands its Size section; returns the Width field's rect.
fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect {
h.state_mut().selected_chart = Some(id);
h.state_mut().select_object_id(Some(id));
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::DisclosureTriangle, "Size").click();
@@ -118,6 +118,51 @@ fn dragging_the_size_field_resizes_the_chart() {
assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}");
}
/// Makes the chart 12,000 wide and reopens the workbook from its XLSX bytes, the way a file can
/// carry a chart larger than the 4,000 points the Size fields otherwise reach; returns its id.
fn imported_oversized_chart(h: &mut Harness, id: u32) -> u32 {
{
let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb);
wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == id).unwrap().anchor.width = 12_000.0;
}
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
h.run_steps(4);
let id = h.state().session.doc().unwrap().wb.active().unwrap().charts[0].id;
let (w, hh) = size(h, id);
assert_eq!((w.round(), hh.round()), (12_000.0, 200.0), "the reader keeps the file's size");
id
}
/// A chart a file made 12,000 wide shows that width in the Size fields, and a new height sends
/// the height alone, so nothing shrinks the width to the fields' 4,000 reach on the way.
#[test]
fn the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes() {
let (mut h, id) = fixture();
let id = imported_oversized_chart(&mut h, id);
open_size_fields(&mut h, id);
let (width_value, height_field) = {
let mut fields = h.get_all_by_role(Role::SpinButton);
let width = fields.next().unwrap();
let height = fields.next().unwrap();
(width.value(), height.rect())
};
assert!(width_value.as_deref().is_some_and(|v| v.contains("12000")), "the field shows the file's width: {width_value:?}");
h.input_mut().events.push(Event::PointerMoved(height_field.center()));
button(&mut h, height_field.center(), true);
button(&mut h, height_field.center(), false);
for digit in ["2", "0", "1"] {
h.input_mut().events.push(Event::Text(digit.into()));
h.run_steps(2);
}
h.key_press(Key::Enter);
h.run_steps(2);
let (w, hh) = size(&h, id);
assert_eq!((w.round(), hh), (12_000.0, 201.0), "the width stays and the height is the typed one");
let (_, p) = h.state().session.journal.iter().rev().find(|(cmd, _)| cmd == "chart.set").unwrap();
assert!(p.get("width").is_none(), "a height edit sends no width: {p}");
}
fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) {
h.input_mut().events.push(Event::PointerMoved(pos));
h.run_steps(2);
@@ -141,7 +186,7 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() {
let (w0, h0) = size(&h, id);
let inside = corner(&h, id) - vec2(100.0, 80.0);
right_click(&mut h, inside, wobble);
assert_eq!(h.state().selected_chart, Some(id), "the right-clicked chart is selected ({wobble:?})");
assert_eq!(h.state().selected_chart(), Some(id), "the right-clicked chart is selected ({wobble:?})");
assert!(matches!(h.state().grid.object_menu, Some((_, "chart", i)) if i == id), "the chart's menu opens ({wobble:?})");
assert!(h.state().grid.context_menu.is_none(), "the cell menu stays closed ({wobble:?})");
assert_eq!(size(&h, id), (w0, h0));
@@ -28,7 +28,7 @@ fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) {
session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap();
}
let mut h = crate::support::harness(session, 4);
h.state_mut().selected_chart = Some(id);
h.state_mut().select_object_id(Some(id));
if frozen {
h.state_mut().view_mut().unwrap().scroll.y = 20.0;
}
@@ -143,7 +143,7 @@ fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() {
let (mut h, id) = fixture(false, false);
assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source");
match state {
"unselected" => h.state_mut().selected_chart = None,
"unselected" => h.state_mut().selected_object = None,
"hidden" => {
h.state_mut().run("view.showObjects", json!({"on": false})).unwrap();
}
@@ -44,7 +44,7 @@ fn typing_a_chart_title_in_format_chart_area_sets_it() {
let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3"})).unwrap();
let id = chart["chart"].as_u64().unwrap() as u32;
let mut h = harness(session);
h.state_mut().selected_chart = Some(id);
h.state_mut().select_object_id(Some(id));
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Title").click();
+2 -2
View File
@@ -259,7 +259,7 @@ fn object_move_and_resize_keep_their_cursors_and_update_the_object() {
let mut h = harness();
let result = h.state_mut().session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap();
let id = result["chart"].as_u64().unwrap() as u32;
h.state_mut().selected_chart = Some(id);
h.state_mut().select_object_id(Some(id));
h.run_steps(2);
let g = geo(&h);
let min = cell(&h, "D3").min;
@@ -268,7 +268,7 @@ fn object_move_and_resize_keep_their_cursors_and_update_the_object() {
let target = start + vec2(60.0, 30.0);
let cursor = if resize { CursorIcon::ResizeNwSe } else { CursorIcon::Move };
move_with_cursor(&mut h, target, cursor);
assert!(matches!(h.state().grid.drag, Drag::Object { resize: r, .. } if r == resize));
assert!(matches!(h.state().grid.drag, Drag::Object { handle, .. } if handle.is_some() == resize));
move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor);
button(&mut h, target, false);
assert_eq!(h.state().grid.drag, Drag::None);
+1 -1
View File
@@ -18,7 +18,7 @@ fn open_chart_type(t: &str, sub: &str) -> (Harness, u32) {
let chart = session.execute("insert.chart", json!({"type": t, "subtype": sub, "at": "E3"})).unwrap();
let id = chart["chart"].as_u64().unwrap() as u32;
let mut h = crate::support::harness(session, 4);
h.state_mut().selected_chart = Some(id);
h.state_mut().select_object_id(Some(id));
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").click();
+66 -9
View File
@@ -1,7 +1,7 @@
//! UI interaction tests: real egui input through the whole app (no GPU needed).
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable as _;
use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::i18n::Language;
@@ -392,12 +392,12 @@ fn delete_key_removes_the_selected_chart() {
click_cell(&mut h, "C10");
click_chart(&mut h, id);
assert_eq!(h.state().selected_chart, Some(id));
assert_eq!(h.state().selected_chart(), Some(id));
key(&mut h, Key::Delete, Modifiers::NONE);
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty());
assert_eq!(h.state().selected_chart, None);
assert_eq!(h.state().selected_chart(), None);
assert_eq!(value(&h, "A1"), Value::Number(1.0));
}
@@ -412,7 +412,7 @@ fn inserted_chart_is_selected_so_delete_removes_it() {
let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32;
h.run_steps(2);
assert_eq!(h.state().selected_chart, Some(id), "the new chart is selected");
assert_eq!(h.state().selected_chart(), Some(id), "the new chart is selected");
key(&mut h, Key::Delete, Modifiers::NONE);
@@ -621,7 +621,7 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() {
assert!(h.state().dialog.is_none());
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::Pie]);
assert!(h.state().selected_chart.is_some(), "the new chart is selected");
assert!(h.state().selected_chart().is_some(), "the new chart is selected");
// Without numbers there is nothing to recommend: a message, no dialog.
h.state_mut().session.run("selection.set", json!({"range": "A1:A4"})).unwrap();
@@ -680,7 +680,7 @@ fn select_data_dialog_sets_the_category_axis_labels() {
s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap();
let mut h = harness(s);
h.state_mut().selected_chart = Some(id as u32);
h.state_mut().select_object_id(Some(id as u32));
h.state_mut().open_dialog("selectData", json!({"chart": id}));
h.run_steps(2);
let d = h.state_mut().dialog.as_mut().unwrap();
@@ -905,6 +905,63 @@ fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn solver_results_skip_the_sensitivity_report_for_integer_models() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("B1", "=3*A1")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r =
s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4.5", "A1 int"], "method": "simplex"})).unwrap();
let mut h = harness(s);
// Sensitivity stays off for an integer model, so the Answer report is still written.
h.state_mut().open_dialog("solverResults", r);
for key in ["answerReport", "sensitivityReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Sheet1"]);
}
#[test]
fn solver_results_offer_sensitivity_only_after_an_optimum() {
// Rosenbrock's function with 60 evaluations stops at the limit with a solution that is not
// an optimum: Sensitivity is disabled, and Answer with Sensitivity ticked writes the Answer
// report.
let mut s = blank();
for (c, v) in [("A1", "-1.2"), ("A2", "1"), ("B1", "=(1-A1)^2+100*(A2-A1^2)^2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute("data.solver", json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "nonNegative": false, "maxEvaluations": 60}))
.unwrap();
assert_eq!(r["status"], "iterationLimit", "{r}");
let mut h = harness(s);
h.state_mut().open_dialog("solverResults", r.clone());
h.run_steps(2);
assert!(h.get_by_label("Sensitivity").accesskit_node().is_disabled());
assert!(!h.get_by_label("Answer").accesskit_node().is_disabled());
for key in ["answerReport", "sensitivityReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Sheet1"]);
// The same model after an optimum offers it.
let mut ok = r;
ok["status"] = json!("converged");
h.state_mut().open_dialog("solverResults", ok);
h.run_steps(2);
assert!(!h.get_by_label("Sensitivity").accesskit_node().is_disabled());
}
#[test]
fn solver_dialog_saves_loads_and_resets_the_model() {
let mut s = blank();
@@ -1201,9 +1258,9 @@ fn solver_results_write_the_chosen_reports() {
)
.unwrap();
let mut h = harness(s);
// Restore Original Values with the Answer and Limits reports: the cells go back, the reports stay.
// Restore Original Values with the three reports: the cells go back, the reports stay.
h.state_mut().open_dialog("solverResults", r);
for key in ["restore", "answerReport", "limitsReport"] {
for key in ["restore", "answerReport", "sensitivityReport", "limitsReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
@@ -1211,7 +1268,7 @@ fn solver_results_write_the_chosen_reports() {
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]);
assert_eq!(names, ["Answer Report 1", "Sensitivity Report 1", "Limits Report 1", "Sheet1"]);
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
+1
View File
@@ -71,6 +71,7 @@ mod more_colors;
mod native_print;
mod nowrap_line_breaks;
mod number_stored_as_text;
mod objects;
mod open_encrypted;
mod outline;
mod page_layout_view;
+426
View File
@@ -0,0 +1,426 @@
//! Floating objects through real pointer and keyboard input: selecting every kind, resizing from
//! each of the eight handles, deleting with Delete and Backspace, and double-click routing.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{ObjKind, ObjRef};
use gridcraft_ui_egui::objects::{Handle, OpenAction, open_action};
use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
struct Objects {
chart: ObjRef,
image: ObjRef,
shape: ObjRef,
text_box: ObjRef,
/// A slicer lying over the chart's right edge: slicers paint on top and take the click.
slicer: ObjRef,
}
fn at(a: &str) -> CellRef {
CellRef::parse(a).unwrap()
}
fn id(v: &serde_json::Value, key: &str) -> u32 {
v[key].as_u64().unwrap() as u32
}
fn fixture() -> (Harness, Objects) {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id(&s.execute("insert.slicer", json!({"column": "Region", "at": "G9", "width": 140, "height": 120})).unwrap(), "slicer");
s.execute("cell.set", json!({"cell": "R30", "input": "keep me"})).unwrap();
s.execute("selection.set", json!({"cell": "R30"})).unwrap();
let chart =
id(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D8", "width": 220, "height": 140})).unwrap(), "chart");
let shape = id(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "C20", "width": 120, "height": 80})).unwrap(), "shape");
let text_box = id(&s.execute("insert.textBox", json!({"at": "H20", "width": 140, "height": 60, "text": "Note"})).unwrap(), "shape");
let image = id(&s.execute("insert.picture", json!({"base64": tiny_png(), "at": "M8", "width": 60, "height": 60})).unwrap(), "image");
let objs = Objects {
chart: ObjRef::new(ObjKind::Chart, chart),
image: ObjRef::new(ObjKind::Image, image),
shape: ObjRef::new(ObjKind::Shape, shape),
text_box: ObjRef::new(ObjKind::Shape, text_box),
slicer: ObjRef::new(ObjKind::Slicer, slicer),
};
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
(h, objs)
}
fn tiny_png() -> String {
let mut bytes = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([40, 120, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.unwrap();
gridcraft_engine::io::base64_encode(&bytes)
}
fn rect(h: &Harness, o: ObjRef) -> Rect {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, o).unwrap()
}
/// A point that selects `o` without doing anything else: the chart's lower left (clear of the
/// slicer), the slicer's header (clear of its value tiles), the middle of anything else.
fn grab_point(h: &Harness, objs: &Objects, o: ObjRef) -> Pos2 {
let r = rect(h, o);
if o == objs.chart {
r.left_bottom() + vec2(12.0, -12.0)
} else if o == objs.slicer {
r.left_top() + vec2(14.0, 10.0)
} else {
r.center()
}
}
fn exists(h: &Harness, o: ObjRef) -> bool {
h.state().session.doc().unwrap().wb.active().unwrap().has_object(o)
}
fn pointer(h: &mut Harness, p: Pos2, pressed: bool) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
fn click(h: &mut Harness, p: Pos2) {
pointer(h, p, true);
pointer(h, p, false);
h.run_steps(2);
}
fn double_click(h: &mut Harness, p: Pos2) {
// Separate the gesture from earlier clicks (egui's triple-click window).
h.run_steps(45);
click(h, p);
click(h, p);
}
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
h.run_steps(45);
pointer(h, from, true);
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * 0.25));
h.step();
h.input_mut().events.push(Event::PointerMoved(to));
h.step();
pointer(h, to, false);
h.run_steps(2);
}
fn key(h: &mut Harness, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn kept(h: &Harness) -> Value {
h.state().session.doc().unwrap().wb.active().unwrap().value(at("R30"))
}
#[test]
fn a_click_selects_every_kind_and_slicers_win_over_charts() {
let (mut h, o) = fixture();
for obj in [o.chart, o.image, o.shape, o.text_box, o.slicer] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
assert_eq!(h.state().selected_object, Some(obj), "{obj:?}");
}
let (chart, slicer) = (rect(&h, o.chart), rect(&h, o.slicer));
assert!(chart.contains(grab_point(&h, &o, o.slicer)) && slicer.intersects(chart), "the slicer's header lies over the chart");
assert_eq!(h.state().selected_chart(), None, "a slicer is not a chart");
click(&mut h, chart.right_bottom() + vec2(120.0, 160.0));
assert_eq!(h.state().selected_object, None, "a click on the cells clears the object selection");
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn every_handle_resizes_only_its_own_edges() {
let (mut h, o) = fixture();
for obj in [o.shape, o.image, o.slicer, o.chart] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
for handle in Handle::ALL {
let before = rect(&h, obj);
let (sx, sy) = handle.sides();
let d = vec2(f32::from(sx) * 20.0, f32::from(sy) * 16.0);
let grab = handle.pos(before);
drag(&mut h, grab, grab + d);
let after = rect(&h, obj);
let moved = |edge: f32, was: f32, by: f32| (edge - (was + by)).abs() < 1.0;
assert!(moved(after.left(), before.left(), if sx < 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} left");
assert!(moved(after.right(), before.right(), if sx > 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} right");
assert!(moved(after.top(), before.top(), if sy < 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} top {before:?} {after:?}");
assert!(moved(after.bottom(), before.bottom(), if sy > 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} bottom");
assert_eq!(h.state().selected_object, Some(obj), "resizing keeps the selection");
}
}
// Shrinking past the opposite edge stops short of it.
let before = rect(&h, o.shape);
click(&mut h, before.center());
drag(&mut h, Handle::Right.pos(before), Handle::Right.pos(before) - vec2(before.width() + 50.0, 0.0));
let after = rect(&h, o.shape);
assert!(after.width() > 1.0 && (after.left() - before.left()).abs() < 1.0);
// Dragging the body moves the object without resizing it.
let before = rect(&h, o.image);
click(&mut h, before.center());
drag(&mut h, before.center(), before.center() + vec2(30.0, 25.0));
let after = rect(&h, o.image);
assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0 && (after.size() - before.size()).length() < 0.5);
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn handles_show_resize_cursors_and_the_body_a_move_cursor() {
let (mut h, o) = fixture();
let r = rect(&h, o.shape);
click(&mut h, r.center());
for (handle, icon) in [
(Handle::TopLeft, egui::CursorIcon::ResizeNwSe),
(Handle::Top, egui::CursorIcon::ResizeVertical),
(Handle::BottomLeft, egui::CursorIcon::ResizeNeSw),
] {
h.input_mut().events.push(Event::PointerMoved(handle.pos(r)));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, icon, "{handle:?}");
}
h.input_mut().events.push(Event::PointerMoved(r.center()));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
}
#[test]
fn delete_and_backspace_delete_the_selected_object_not_the_cells() {
let (mut h, o) = fixture();
let p = rect(&h, o.shape).center();
click(&mut h, p);
key(&mut h, Key::Delete);
assert!(!exists(&h, o.shape));
assert_eq!(h.state().selected_object, None);
assert_eq!(kept(&h), Value::text("keep me"));
let p = rect(&h, o.image).center();
click(&mut h, p);
key(&mut h, Key::Backspace);
assert!(!exists(&h, o.image));
assert!(h.state().editor.is_none(), "Backspace on an object must not start a cell editor");
assert_eq!(kept(&h), Value::text("keep me"));
let p = grab_point(&h, &o, o.slicer);
click(&mut h, p);
assert_eq!(h.state().selected_object, Some(o.slicer));
key(&mut h, Key::Escape);
assert_eq!(h.state().selected_object, None, "Escape drops the object selection");
key(&mut h, Key::Delete);
assert!(exists(&h, o.slicer), "with nothing selected, Delete is for the cells");
assert_eq!(kept(&h), Value::Empty);
// Undo brings the object back; a redo that removes the selected object clears the selection.
h.state_mut().run("edit.undo", json!({})).unwrap();
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(exists(&h, o.image));
h.state_mut().selected_object = Some(o.image);
h.state_mut().run("edit.redo", json!({})).unwrap();
h.run_steps(2);
assert!(!exists(&h, o.image));
assert_eq!(h.state().selected_object, None);
}
#[test]
fn double_clicks_route_by_kind() {
let (mut h, o) = fixture();
{
let sh = h.state().session.doc().unwrap().wb.active().unwrap();
assert_eq!(open_action(sh, o.text_box), OpenAction::EditText(o.text_box.id));
assert_eq!(open_action(sh, o.chart), OpenAction::FormatChart(o.chart.id));
for obj in [o.shape, o.image, o.slicer] {
assert_eq!(open_action(sh, obj), OpenAction::Select, "{obj:?}");
}
}
let p = rect(&h, o.text_box).center();
double_click(&mut h, p);
assert!(h.state().text_box_editor.as_ref().is_some_and(|e| e.id == o.text_box.id), "text boxes keep in-place editing");
let outside = rect(&h, o.text_box).right_bottom() + vec2(200.0, 150.0);
click(&mut h, outside);
assert!(h.state().text_box_editor.is_none());
let p = grab_point(&h, &o, o.chart);
double_click(&mut h, p);
assert_eq!(h.state().grid.pane.as_deref(), Some("formatChart"));
assert_eq!(h.state().selected_object, Some(o.chart));
for obj in [o.shape, o.image, o.slicer] {
let p = grab_point(&h, &o, obj);
double_click(&mut h, p);
assert_eq!(h.state().selected_object, Some(obj));
assert!(h.state().text_box_editor.is_none() && h.state().editor.is_none(), "{obj:?} opens no text editor");
}
assert_eq!(kept(&h), Value::text("keep me"));
}
#[test]
fn on_a_right_to_left_sheet_the_mirrored_handles_resize() {
let (mut h, o) = fixture();
h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap();
h.run_steps(3);
let screen = |h: &Harness| {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
geo.mirror_rect(chartview::object_rect(&geo, sh, o.image).unwrap())
};
let before = screen(&h);
click(&mut h, before.center());
assert_eq!(h.state().selected_object, Some(o.image), "the picture is hit where it is drawn");
// The logical bottom-right handle is drawn at the bottom-left; dragging it leftwards widens the picture.
let grab = before.left_bottom();
h.input_mut().events.push(Event::PointerMoved(grab));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw);
drag(&mut h, grab, grab + vec2(-20.0, 16.0));
let after = screen(&h);
assert!((after.left() - (before.left() - 20.0)).abs() < 1.0 && (after.right() - before.right()).abs() < 1.0, "{before:?} {after:?}");
assert!((after.bottom() - (before.bottom() + 16.0)).abs() < 1.0 && (after.top() - before.top()).abs() < 1.0, "{before:?} {after:?}");
}
#[test]
fn a_handle_under_the_headers_does_not_take_the_press() {
let (mut h, o) = fixture();
h.state_mut().run("object.move", json!({"kind": "shape", "id": o.shape.id, "at": "A1", "dx": 0, "dy": 0})).unwrap();
h.run_steps(2);
let before = rect(&h, o.shape);
click(&mut h, before.center());
assert_eq!(h.state().selected_object, Some(o.shape));
let cells = h.state().grid.cells_rect.unwrap();
assert_eq!(before.left_top(), cells.left_top(), "the shape's top-left handle sits on the header corner");
// Within the top-left handle's reach, but over the column header: the press is the header's.
let grab = Handle::TopLeft.pos(before) + vec2(4.0, -4.0);
assert!(grab.y < cells.top());
drag(&mut h, grab, grab + vec2(30.0, 40.0));
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
assert_eq!(h.state().selected_object, None, "the press went to the column header");
// Within the left handle's reach, but over the row header.
let grab = Handle::Left.pos(before) + vec2(-4.0, 0.0);
assert!(grab.x < cells.left());
h.state_mut().selected_object = Some(o.shape);
drag(&mut h, grab, grab + vec2(40.0, 0.0));
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
}
#[test]
fn each_drag_of_an_object_is_its_own_undo_step() {
let (mut h, o) = fixture();
let start = rect(&h, o.image);
click(&mut h, start.center());
drag(&mut h, start.center(), start.center() + vec2(30.0, 20.0));
let moved = rect(&h, o.image);
assert!((moved.min - start.min - vec2(30.0, 20.0)).length() < 1.0);
let grab = Handle::BottomRight.pos(moved);
drag(&mut h, grab, grab + vec2(24.0, 18.0));
let resized = rect(&h, o.image);
assert!((resized.size() - moved.size() - vec2(24.0, 18.0)).length() < 1.0);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(rect(&h, o.image), moved, "one undo takes back the resize only");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(rect(&h, o.image), start, "a second undo takes back the move");
}
#[test]
fn dragging_an_unselected_object_by_its_edge_moves_it() {
let (mut h, o) = fixture();
assert_eq!(h.state().selected_object, None);
let before = rect(&h, o.shape);
// On the bottom-right corner, where the handle would be if the shape were selected; the
// pointer shows Move there, and the press selects and moves the shape.
let grab = Handle::BottomRight.pos(before) - vec2(2.0, 2.0);
h.input_mut().events.push(Event::PointerMoved(grab));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
drag(&mut h, grab, grab + vec2(30.0, 20.0));
let after = rect(&h, o.shape);
assert_eq!(h.state().selected_object, Some(o.shape));
assert!((after.size() - before.size()).length() < 0.5, "not resized: {before:?} {after:?}");
assert!((after.min - before.min - vec2(30.0, 20.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
}
fn anchor(h: &Harness, o: ObjRef) -> gridcraft_engine::model::Anchor {
h.state().session.doc().unwrap().wb.active().unwrap().object_anchor(o).unwrap()
}
/// Makes the fixture's picture 12,000 by 60 and reopens the workbook from its XLSX bytes, the way
/// a file can carry an object wider than the 10,000 points `object.move` gives a new size.
/// Returns the picture as reopened.
fn imported_oversized_picture(h: &mut Harness, o: &Objects) -> ObjRef {
{
let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb);
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(o.image).unwrap();
a.width = 12_000.0;
a.height = 60.0;
}
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
h.run_steps(4);
let pic = ObjRef::new(ObjKind::Image, h.state().session.doc().unwrap().wb.active().unwrap().images[0].id);
let a = anchor(h, pic);
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the reader keeps the file's size: {a:?}");
pic
}
/// Dragging the body of such a picture moves it and sends no size, so nothing shrinks it to the
/// limit on the way.
#[test]
fn dragging_the_body_of_an_oversized_imported_object_keeps_its_size() {
let (mut h, o) = fixture();
let pic = imported_oversized_picture(&mut h, &o);
let before = rect(&h, pic);
// Well inside the body, on the part of the picture in view and clear of the handles' reach.
let grab = before.left_top() + vec2(30.0, 30.0);
click(&mut h, grab);
assert_eq!(h.state().selected_object, Some(pic));
drag(&mut h, grab, grab + vec2(30.0, 25.0));
let a = anchor(&h, pic);
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the size is untouched: {a:?}");
let after = rect(&h, pic);
assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
// The drag sent the position only: the engine saw no size to cap.
let (_, p) = h.state().session.journal.iter().rev().find(|(id, _)| id == "object.move").unwrap();
assert!(p.get("width").is_none() && p.get("height").is_none(), "a body move sends no size: {p}");
}
/// A handle drag on such a picture resizes it by the drag only: the left handle moved 10 px to
/// the right leaves it 11,990 wide, with no jump to 10,000.
#[test]
fn a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only() {
let (mut h, o) = fixture();
let pic = imported_oversized_picture(&mut h, &o);
let before = rect(&h, pic);
click(&mut h, before.left_top() + vec2(30.0, 30.0));
let grab = Handle::Left.pos(before);
drag(&mut h, grab, grab + vec2(10.0, 0.0));
// In window points (the grid draws at its zoom): 10 narrower, the left edge 10 further right.
let after = rect(&h, pic);
assert!((before.width() - after.width() - 10.0).abs() < 1.0, "narrower by the drag only: {before:?} {after:?}");
assert!((after.left() - before.left() - 10.0).abs() < 1.0 && (after.top() - before.top()).abs() < 0.5, "{before:?} {after:?}");
let a = anchor(&h, pic);
assert!(a.width > 11_900.0 && a.width < 12_000.0 && a.height.round() == 60.0, "no jump to the 10,000 limit: {a:?}");
}
+3 -3
View File
@@ -86,11 +86,11 @@ fn hidden_objects_do_not_take_clicks() {
let (mut h, id) = fixture();
let at = inside(&h);
click(&mut h, at);
assert_eq!(h.state().selected_chart, Some(id), "the click lands on the rectangle while it shows");
assert_eq!(h.state().selected_object.map(|o| o.id), Some(id), "the click lands on the rectangle while it shows");
assert_eq!(active_cell(&h), CellRef::parse("A1").unwrap());
ctrl_6(&mut h);
assert_eq!(h.state().selected_chart, None, "hiding objects drops the selection on one");
assert_eq!(h.state().selected_object, None, "hiding objects drops the selection on one");
click(&mut h, at);
assert_eq!(h.state().selected_chart, None);
assert_eq!(h.state().selected_object, None);
assert_eq!(active_cell(&h), CellRef::parse("E4").unwrap(), "the click selects the cell under the hidden rectangle");
}
+1 -1
View File
@@ -102,7 +102,7 @@ fn double_click_edits_multiline_text_and_commits_on_outside_click() {
let (mut h, id) = fixture();
edit_box(&mut h, id);
h.check_accessibility();
assert_eq!(h.state().selected_chart, Some(id));
assert_eq!(h.state().selected_object.map(|o| o.id), Some(id));
let anchor = shape(&h, id).anchor;
let start = shape_rect(&h, id).min + vec2(5.0, 10.0);
drag(&mut h, start, start + vec2(60.0, 0.0));
+9 -9
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-02 · **Change:** minor (charts resize from eight handles; earlier: chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
@@ -37,7 +37,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
| Element | Excel | GridCraft |
|---|---|---|
| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) |
| Move and resize the chart object | yes | yes (eight handles, each moving only its own edges; Format Chart Area Size fields) (#190) |
| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) |
| Chart title, axis titles | text, linked to cells, full formatting | text only |
| Legend | position, overlay, formatting, per-entry delete | position only |
@@ -93,18 +93,18 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
## Work (40–60 h)
1. Chart resizing (#190): 1–2 h.
2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
4. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.
1. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
2. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
3. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
4. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
5. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
6. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Charts move and resize from eight handles, each moving only its own edges (#190); resizing leaves the work list (1–2 h, within the 40–60 h rounding) |
| 2026-11-01 | minor | Chart data tables for column, bar, line, area and compatible combo charts: command/UI controls, formatted source values, blanks, grouped category headers, legend keys, independent borders, shared print/PDF rendering and native c:dTable import/export. Dense tables show a space notice; no readiness score change. |
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
+12 -1
View File
@@ -58,7 +58,8 @@ connection; nothing sent after it runs. Blank lines are skipped.
| `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels |
| `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range |
| `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link |
| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` |
| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, selected object, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` |
| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet and replies `{selected, rect}`, the rect in window points; with no `id` it clears the selection and replies `null` |
| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) |
| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits |
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB |
@@ -90,6 +91,16 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th
(`window.*`, `view.newWindow`, `view.hideWindow`, `view.unhideWindow`, `view.switchWindows`) read
them as their own parameters.
`ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`),
where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter
of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands.
`object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's
current size when a file made it larger: such an object keeps its size when it is moved and
shrinks by what a resize asks, but does not grow further.
`selectedChart` remains for existing clients: the id when the selection is a chart, otherwise
`null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object
instead of clearing cells, and Escape clears the selection.
### Windows
Every open workbook has one or more windows; View › New Window (`view.newWindow`) adds another
+9 -7
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (§13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -46,10 +46,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Missing:** axis scaling, label options, error bars, per-point formatting.
- **Added:** data tables beneath column, bar, line, area and compatible combo charts, with legend keys, border controls, live source values, native XLSX round-trip and print/PDF.
- **Evidence:** `Chart` holds only title, legend position, two flags and axis titles
(`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back.
(`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts
can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read,
written, drawn) landed.
resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines
(read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn
and written back.
- **Impact:** charts are in most business workbooks.
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
@@ -121,12 +121,12 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in, including Forecast
Start (hindcast, #785).
- Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local
nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and
Limits reports. Report refusal leaves the solution, saved model and history unchanged; a
nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer,
Sensitivity and Limits reports. Report refusal leaves the solution, saved model and history unchanged; a
successful solve and its reports share one Undo step (#1050). Load/Save Model writes the
model on cells of its sheet and reads it back; Reset All removes it. Solver Options sets the
evaluation limit, Integer Optimality and the Evolutionary options, which are saved with the
model. AllDifferent constraints and the Sensitivity and Population reports remain.
model. AllDifferent constraints and the Population report remain.
- The Analysis ToolPak (`data.analysisToolPak`) has all 19 of Excel's tools; their chart output
remains. **Estimate:** 12–20 h.
@@ -185,6 +185,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | §13: Solver Sensitivity report: shadow prices, reduced costs and allowable ranges for Simplex LP; Lagrange multipliers and reduced gradients for GRG Nonlinear |
| 2026-11-02 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" |
| 2026-11-01 | minor | §13: the Analysis ToolPak has all 19 of Excel's tools (two-factor Anova with and without replication, z-Test, Fourier Analysis, Random Number Generation, Sampling); its chart output remains. The stale "`data.solver`, `data.analysisToolPak` missing" and "no Forecast Start" sentences removed |
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
+8 -6
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -29,7 +29,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|---|---|---|---|
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart polish (5–8 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
calibration](#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
@@ -89,7 +89,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
@@ -134,7 +134,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the
| Cell formatting, styles, conditional formatting | 13 | 80 | Feature-area table |
| Sort, filter, tables, validation | 10 | 70 | Feature-area table |
| PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent |
| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) |
| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; trendlines read, written and drawn; no axis options; moved and resized from eight handles (#190) |
| Page layout, print, PDF | 5 | 60 | Latin text exports |
| Comments, notes, protection | 3 | 55 | Feature-area table |
| Pictures and shapes | 2 | 45 | Feature-area table |
@@ -144,7 +144,7 @@ Discounts for what still stops real work:
| Discount | Factor | Evidence |
|---|---|---|
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) |
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); missing horizontal scrollbar report (#176) |
| Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) |
| File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly |
@@ -167,7 +167,7 @@ edge cases are left out, along with everything excluded from the mainstream numb
| Rows and columns: insert, delete, widths, AutoFit | 10 | 82 | AutoFit fixed (#42, #88); rows auto-fit oversized fonts on open and row formatting (#833) |
| Sort and filter | 10 | 82 | |
| Save, Save As, reopen XLSX | 10 | 85 | |
| A basic chart | 8 | 55 | Created easily; can't be resized (#190) |
| A basic chart | 8 | 55 | Created easily; moved and resized from eight handles (#190) |
| Print and PDF | 7 | 65 | Desktop works; web PDF gives no file or feedback (#141) |
| Find and replace | 5 | 85 | |
| **Average depth** | 100 | **80.2** | |
@@ -334,6 +334,8 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report (no score change) |
| 2026-11-02 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) |
| 2026-11-01 | minor | What-if and analysis: Analysis ToolPak has all 19 tools (was 13 of 19; no charts); no score change |
| 2026-11-01 | minor | Charts: data tables for column, bar, line, area and compatible combo charts, with legend keys and borders; live source values, native XLSX round-trip and shared print/PDF rendering. Readiness estimates unchanged. |
| 2026-11-01 | minor | Page layout and print: Page Setup's page order (Down, then over or Over, then down), kept in XLSX; no score change |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -68,7 +68,7 @@ this logic, with one undo step.
| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
| Copy as HTML table for other apps | done (#36, 10-10) |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Move and resize charts, pictures and shapes | charts can't be resized (#190) |
| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing |
| Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
| Quick Analysis (Ctrl+Q) | missing |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
@@ -196,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection |
| 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |
| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too |
| 2026-11-01 | minor | Formulas › Error Checking › Trace Error (`formulas.traceError`): red arrows from the cell an error starts in to the selected cell, through the first reference showing the same error at each step, and blue arrows from that cell's own references, as measured in Excel; the command returns the source and the arrows |