Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls

Both sides added a kind of floating object (equation, form control); both are kept everywhere.
ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows.
crates/engine/src/cmd.rs: both spec lists (controls and equation).
crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds.
crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly.
crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds.
crates/engine/src/tests.rs: both test modules.
crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both.
crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both.
crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes.
crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment.
crates/ui-egui/src/chartview.rs: both doc comments combined.
crates/ui-egui/src/objects.rs: both OpenAction variants and arms.
crates/ui-egui/src/ribbon/insert.rs: the import list is the union.
docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence.
docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows.
docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296).
docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's.
docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
This commit is contained in:
prxnvt
2026-11-02 10:47:43 -05:00
95 changed files with 6317 additions and 218 deletions
+171 -1
View File
@@ -631,6 +631,9 @@ pub enum ShapeKind {
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
Ink,
/// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour
/// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout.
Equation,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -644,6 +647,97 @@ pub struct Shape {
/// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
#[serde(default)]
pub format: ShapeFormat,
/// An equation's layout, rebuilt from `text` and never saved.
#[serde(skip)]
pub math: EquationCache,
}
/// The laid-out form of an equation's LaTeX source. It is derived data: rebuilt by
/// [`EquationCache::refresh`] whenever the source changes, never saved, and ignored when shapes
/// are compared. Painters read it and never lay equations out themselves, so nothing in a paint
/// path can reach the layout engine.
///
/// An entry answers only for the source it was built from, compared in full: a hash match alone
/// is not enough, so two sources whose hashes collide never share a layout. A source over
/// [`gridcraft_math::MAX_SOURCE_BYTES`] never lays out, so [`EquationCache::get`] answers for it
/// by its length alone, with [`gridcraft_math::SOURCE_TOO_LONG`], and an entry built from one holds
/// neither the source nor its hash: a painter asking every frame about a source of megabytes read
/// from a file does no work on it.
#[derive(Clone, Debug, Default)]
pub struct EquationCache {
/// A hash of the source the entry was built from (a quick first check).
key: u64,
/// The source's length in bytes.
len: usize,
/// The source itself, when it is within the length cap.
source: Option<std::sync::Arc<str>>,
layout: Option<Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>>,
}
impl PartialEq for EquationCache {
fn eq(&self, _: &Self) -> bool {
true
}
}
fn source_key(src: &str) -> u64 {
use std::hash::{Hash, Hasher};
#[cfg(test)]
equation_cache_tests::HASHES.with(|h| h.set(h.get() + 1));
let mut h = std::collections::hash_map::DefaultHasher::new();
src.hash(&mut h);
h.finish()
}
impl EquationCache {
/// Whether this entry was built from `src`. Over the cap the length is the match (an entry for
/// such a source is always the same error); within it, the hash and then the source itself.
fn holds(&self, src: &str) -> bool {
if self.layout.is_none() || self.len != src.len() {
return false;
}
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
return self.source.is_none();
}
self.key == source_key(src) && self.source.as_deref() == Some(src)
}
/// Makes this the entry for `src`, with `layout`. A source over the cap is neither copied nor
/// hashed.
fn store(&mut self, src: &str, layout: Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>) {
let within = src.len() <= gridcraft_math::MAX_SOURCE_BYTES;
self.key = if within { source_key(src) } else { 0 };
self.len = src.len();
self.source = within.then(|| std::sync::Arc::from(src));
self.layout = Some(layout);
}
/// The layout of `src`, or why it has none; `None` until [`EquationCache::refresh`] has run
/// for this source. A source over [`gridcraft_math::MAX_SOURCE_BYTES`] is answered at once,
/// whatever the entry holds, by its length alone.
pub fn get(&self, src: &str) -> Option<Result<&gridcraft_math::MathList, &gridcraft_math::MathError>> {
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
return Some(Err(&*gridcraft_math::SOURCE_TOO_LONG));
}
if !self.holds(src) {
return None;
}
self.layout.as_ref().map(|r| r.as_ref().map(|l| l.as_ref()))
}
/// Lays `src` out again unless the cache already holds its layout. True when it did.
pub fn refresh(&mut self, src: &str) -> bool {
if self.holds(src) {
return false;
}
self.store(src, gridcraft_math::layout(src).map(std::sync::Arc::new));
true
}
/// Records that `src` was not laid out, and why, without running the layout engine.
pub fn refuse(&mut self, src: &str, message: &str) {
self.store(src, Err(gridcraft_math::MathError { span: None, message: message.to_string() }));
}
}
/// What a form control on the sheet is.
@@ -813,6 +907,8 @@ impl FormControl {
/// 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.
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
/// own kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ObjKind {
@@ -820,11 +916,12 @@ pub enum ObjKind {
Image,
Shape,
Slicer,
Equation,
Control,
}
impl ObjKind {
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control];
pub const ALL: [ObjKind; 6] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation, ObjKind::Control];
/// The name used in command parameters.
pub fn name(self) -> &'static str {
@@ -833,6 +930,7 @@ impl ObjKind {
ObjKind::Image => "image",
ObjKind::Shape => "shape",
ObjKind::Slicer => "slicer",
ObjKind::Equation => "equation",
ObjKind::Control => "control",
}
}
@@ -989,3 +1087,75 @@ mod trendline_forecast_tests {
assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST);
}
}
#[cfg(test)]
mod equation_cache_tests {
use super::{EquationCache, source_key};
thread_local! {
/// How many sources `source_key` has hashed on this thread.
pub(super) static HASHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn glyphs(c: &EquationCache, src: &str) -> Option<String> {
let list = c.get(src)?.ok()?;
Some(list.items.iter().filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }).collect())
}
#[test]
fn a_hash_collision_never_returns_another_sources_layout() {
let (a, b) = ("x^2", "y^3");
let mut c = EquationCache::default();
assert!(c.refresh(a));
assert_eq!(glyphs(&c, a).as_deref(), Some("x2"));
// Forge a collision: the entry claims `b`'s hash; `b` has `a`'s length, so only the
// full comparison tells them apart.
c.key = source_key(b);
assert!(c.get(b).is_none(), "`b` is a miss, not `a`'s layout");
assert!(c.refresh(b), "`b` is laid out afresh");
assert_eq!(glyphs(&c, b).as_deref(), Some("y3"));
assert!(c.get(a).is_none(), "the entry is `b`'s now");
// A refusal does not answer for a colliding source either.
c.refuse(a, "not laid out");
c.key = source_key(b);
assert!(c.get(b).is_none());
}
#[test]
fn sources_over_the_cap_are_not_copied_hashed_or_laid_out() {
let long = "x".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
let mut c = EquationCache::default();
HASHES.with(|h| h.set(0));
assert!(matches!(c.get(&long), Some(Err(e)) if e.message.contains("4096")), "answered before any refresh");
assert!(c.refresh(&long));
assert!(c.source.is_none(), "an over-cap source is not kept");
assert_eq!(c.key, 0, "nor hashed");
assert!(matches!(c.get(&long), Some(Err(_))));
assert!(!c.refresh(&long), "still cached");
let other = "y".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
assert!(matches!(c.get(&other), Some(Err(_))), "another over-cap source gets the same answer");
assert_eq!(HASHES.with(|h| h.get()), 0, "nothing over the cap is hashed");
// Within the cap the lookup is unchanged: a hash, then the source compared in full.
assert!(c.refresh("x"));
assert!(c.get("x").is_some_and(|r| r.is_ok()));
assert!(HASHES.with(|h| h.get()) >= 2);
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn a_source_of_a_megabyte_is_looked_up_without_being_read() {
// The sheet painter asks the cache every frame and the PDF export every draw; a source
// read from a file can be megabytes, and the lookup must not scan it each time.
let big = "l".repeat(1 << 20);
let mut c = EquationCache::default();
c.refuse(&big, "budget");
HASHES.with(|h| h.set(0));
let start = std::time::Instant::now();
for _ in 0..1000 {
assert!(matches!(c.get(&big), Some(Err(_))));
}
let took = start.elapsed();
assert_eq!(HASHES.with(|h| h.get()), 0, "a thousand lookups read nothing");
assert!(took < std::time::Duration::from_millis(500), "{took:?}");
}
}
+4 -1
View File
@@ -27,7 +27,10 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
pub use workbook::{
CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString,
ShowObjects, Workbook, WorkbookView,
};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
+25 -14
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape,
SheetProtection, Slicer, Sparkline, Table, Validation,
ShapeKind, SheetProtection, Slicer, Sparkline, Table, Validation,
};
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -213,8 +213,8 @@ impl Sheet {
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 let Some(s) = self.shapes.iter().find(|o| o.id == id) {
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
} else if self.slicers.iter().any(|o| o.id == id) {
ObjKind::Slicer
} else if self.controls.iter().any(|o| o.id == id) {
@@ -225,7 +225,8 @@ impl Sheet {
Some(ObjRef::new(kind, id))
}
/// Whether the floating object `o` exists on this sheet.
/// Whether the floating object `o` exists on this sheet. `Shape` accepts any shape (an
/// equation included); `Equation` only an equation.
pub fn has_object(&self, o: ObjRef) -> bool {
self.object_anchor(o).is_some()
}
@@ -237,6 +238,7 @@ impl Sheet {
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),
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
ObjKind::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor),
}
}
@@ -247,6 +249,7 @@ impl Sheet {
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),
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
ObjKind::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
}
}
@@ -263,6 +266,7 @@ impl Sheet {
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),
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
ObjKind::Control => take(&mut self.controls, |c| c.id == o.id),
}
}
@@ -714,12 +718,13 @@ mod tests {
assert_eq!(s.col_left(2), 128.0);
}
use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind};
use crate::features::{ChartKind, EquationCache, 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.
/// then catch an accessor that was not taught about it. An equation is a shape of
/// `ShapeKind::Equation`, so it lives in `shapes` and is visited among them.
fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) {
match kind {
ObjKind::Chart => s.charts.push(Chart {
@@ -740,14 +745,15 @@ mod tests {
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 {
ObjKind::Shape | ObjKind::Equation => s.shapes.push(Shape {
id,
kind: ShapeKind::default(),
kind: if kind == ObjKind::Equation { ShapeKind::Equation } else { ShapeKind::default() },
anchor,
fill: Color::default(),
line: Color::default(),
text: String::new(),
format: ShapeFormat::default(),
math: EquationCache::default(),
}),
ObjKind::Slicer => {
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
@@ -773,9 +779,11 @@ mod tests {
(ObjKind::Chart, 9),
(ObjKind::Slicer, 1),
(ObjKind::Image, 4),
(ObjKind::Control, 6),
(ObjKind::Equation, 6),
(ObjKind::Control, 13),
(ObjKind::Shape, 12),
(ObjKind::Control, 10),
(ObjKind::Equation, 8),
(ObjKind::Control, 15),
];
for &(kind, id) in &inserted {
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
@@ -791,10 +799,13 @@ mod tests {
}
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, controls, 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();
// The sequence is the documented one (charts, images, shapes, slicers, controls, each in
// insertion order; equations are shapes) and every object appears in it once.
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]
.iter()
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*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
+31
View File
@@ -11,6 +11,13 @@ use crate::cell::Formula;
use crate::sheet::Sheet;
use crate::style::{StyleTable, Theme};
/// Most equation source, in bytes, one [`Workbook::refresh_equations`] lays out: about 2,500
/// typical equations, or a few seconds of the slowest 4 KB sources.
pub const MAX_EQUATION_LAYOUT_BYTES: usize = 128 * 1024;
/// Why an equation past [`MAX_EQUATION_LAYOUT_BYTES`] has no layout.
pub const EQUATION_BUDGET_MESSAGE: &str = "This workbook has more equations than are laid out at once; edit this equation to lay it out.";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CalcMode {
#[default]
@@ -463,6 +470,30 @@ impl Workbook {
}
None
}
/// Rebuilds the cached layout of every equation whose source changed (or was never laid
/// out, as after loading a file). One call lays out at most [`MAX_EQUATION_LAYOUT_BYTES`] of
/// source: one equation can take a tenth of a second to lay out, so a file with thousands of
/// them would otherwise hold the app for minutes. Equations past the budget get an error
/// instead (they show their source; editing one lays it out).
pub fn refresh_equations(&mut self) {
let stale = |s: &crate::Shape| s.kind == crate::ShapeKind::Equation && s.math.get(&s.text).is_none();
let mut budget = MAX_EQUATION_LAYOUT_BYTES;
// Only sheets with a stale equation are copied (sheets are shared with undo snapshots).
for sh in self.sheets.iter_mut().filter(|sh| sh.shapes.iter().any(stale)) {
for s in Arc::make_mut(sh).shapes.iter_mut().filter(|s| stale(s)) {
// A source over the length cap never gets here: the cache answers for it without
// a lookup. An empty source still costs a call.
let cost = s.text.len().max(1);
if cost > budget {
s.math.refuse(&s.text, EQUATION_BUDGET_MESSAGE);
continue;
}
budget -= cost;
s.math.refresh(&s.text);
}
}
}
/// A free id for a new chart, picture, shape, table, slicer or form control: 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 {