Merge w/objects (05851de4) into w/form-controls

crates/engine/src/cmd/insert.rs: the object.move params line keeps both the `control` kind (this branch) and the size-cap note (w/objects).
crates/engine/src/tests/objects.rs: both sides' new tests kept, the copied-sheet controls test first, then the two oversized-object tests.
crates/ui-egui/tests/it/chart_data_table.rs: w/objects' own fix (21a27a76, `selected_object = Some(ObjRef::new(ObjKind::Chart, id))`) replaces the equivalent select_object_id line of dcefd523.
docs/control-protocol.md: this branch's `control` kind paragraph, followed by w/objects' sentences on the object.move and chart.set size cap.
crates/model/src/sheet.rs: git merged both sides' `data_table: None` lines into the push_object Chart literal (dcefd523 and 21a27a76 made the same change at different places); the w/objects line is kept, the dcefd523 one dropped.
This commit is contained in:
prxnvt
2026-11-02 02:09:25 -05:00
9 changed files with 218 additions and 13 deletions
+17 -5
View File
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer|control, id, at?: \"C3\", dx?, dy?, width?, height?}",
"{kind: chart|image|shape|slicer|control, 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
),
@@ -933,10 +933,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;
@@ -1250,6 +1250,18 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
/// 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 (sheet, o) = object_param(s, p, "object.move")?;
let at = cell_param(s, p, "at")?;
@@ -1265,10 +1277,10 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
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)
})
+58
View File
@@ -331,3 +331,61 @@ fn a_copied_sheets_form_controls_take_free_ids_too() {
assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}");
assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id");
}
/// 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");
}
+1 -1
View File
@@ -730,6 +730,7 @@ mod tests {
series: vec![],
legend: LegendPos::default(),
data_labels: false,
data_table: None,
gridlines: false,
style: 0,
x_title: None,
@@ -737,7 +738,6 @@ mod tests {
source: None,
by_rows: false,
pivot: None,
data_table: 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 {
+10 -2
View File
@@ -987,11 +987,19 @@ fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) {
};
// 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 params = json!({
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,
"width": x1 - x0, "height": y1 - y0,
});
// 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 {
+20 -4
View File
@@ -308,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)));
+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().select_object_id(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();
+45
View File
@@ -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);
+62
View File
@@ -362,3 +362,65 @@ fn dragging_an_unselected_object_by_its_edge_moves_it() {
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
View File
@@ -96,6 +96,9 @@ where `kind` is `chart`, `image`, `shape`, `slicer` or `control` (a form button,
button); the same names go in the `kind` parameter of `object.delete`, `object.move`,
`object.setAnchorMode` and the `arrange.*` commands. Form controls are worked with
`control.setValue` and `control.press`, and listed by `control.list`.
`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.