Files
gridcraft/crates/ui-egui/tests/it/objects.rs
T
prxnvt af28f01bfd Objects: dragging an object's body sends its position only, not its size
`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.

Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.
2026-11-02 01:58:23 -05:00

427 lines
19 KiB
Rust

//! 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:?}");
}