mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 12:29:58 -06:00
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.
497 lines
23 KiB
Rust
497 lines
23 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,
|
|
equation: 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 equation = id(&s.execute("insert.equation", json!({"latex": "a^2+b^2=c^2", "cell": "C26", "size": 16})).unwrap(), "equation");
|
|
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),
|
|
equation: ObjRef::new(ObjKind::Equation, equation),
|
|
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 click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) {
|
|
h.run_steps(30);
|
|
for pressed in [true, false] {
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::PointerMoved(p));
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
|
|
h.step();
|
|
}
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
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"))
|
|
}
|
|
|
|
/// A form control over a slicer's value tile is the topmost object there, so a click that
|
|
/// `form_controls::interact` leaves to the grid (Cmd+click selects the control; a click on a
|
|
/// selected control moves it) takes the generic object path and never toggles the tile beneath.
|
|
#[test]
|
|
fn a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click() {
|
|
let (mut h, o) = fixture();
|
|
let slicer = rect(&h, o.slicer);
|
|
// A button exactly over the slicer's first value tile: the tile in screen units at the
|
|
// grid's scale, then the button's anchor in sheet units from the slicer's own.
|
|
let (z, anchor, n, columns) = {
|
|
let app = h.state();
|
|
let d = app.session.doc().unwrap();
|
|
let sh = d.wb.active().unwrap();
|
|
let sl = sh.slicers.iter().find(|s| s.id == o.slicer.id).unwrap();
|
|
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
|
|
(geo.z, sl.anchor, gridcraft_engine::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet(), sl).len(), sl.columns)
|
|
};
|
|
let layout = chartview::slicer_layout(slicer, z, n, columns);
|
|
let tile = layout.tiles[0];
|
|
let params = json!({
|
|
"at": anchor.cell.a1(),
|
|
"dx": anchor.dx + (tile.left() - slicer.left()) / z,
|
|
"dy": anchor.dy + (tile.top() - slicer.top()) / z,
|
|
"width": tile.width() / z,
|
|
"height": tile.height() / z,
|
|
"text": "Over",
|
|
});
|
|
let button = ObjRef::new(ObjKind::Control, id(&h.state_mut().run("insert.button", params).unwrap(), "control"));
|
|
h.run_steps(2);
|
|
assert!(rect(&h, button).contains(tile.center()), "the button covers the tile: {:?} vs {tile:?}", rect(&h, button));
|
|
let filter =
|
|
|h: &Harness| h.state().session.doc().unwrap().wb.active().unwrap().slicers.iter().find(|s| s.id == o.slicer.id).unwrap().selected.clone();
|
|
assert_eq!(filter(&h), None);
|
|
// Cmd+click selects the control without working it, and the tile beneath stays as it was.
|
|
click_with(&mut h, tile.center(), Modifiers::COMMAND);
|
|
assert_eq!(h.state().selected_object, Some(button), "the control is selected");
|
|
assert_eq!(filter(&h), None, "the tile under the control was not toggled");
|
|
// A click on the selected control is for moving it, not for the tile beneath either.
|
|
click(&mut h, tile.center());
|
|
assert_eq!(h.state().selected_object, Some(button));
|
|
assert_eq!(filter(&h), None, "still no toggle");
|
|
// The tile itself, clear of the control, still toggles.
|
|
let free = layout.tiles[1].center();
|
|
h.state_mut().selected_object = None;
|
|
click(&mut h, free);
|
|
assert!(filter(&h).is_some(), "a click on an uncovered tile toggles it");
|
|
}
|
|
|
|
#[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.equation, 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.equation, 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));
|
|
assert_eq!(open_action(sh, o.equation), OpenAction::EditEquation(o.equation.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));
|
|
|
|
let p = grab_point(&h, &o, o.equation);
|
|
double_click(&mut h, p);
|
|
assert_eq!(h.state().selected_object, Some(o.equation));
|
|
assert_eq!(h.state().dialog.as_ref().map(|d| (d.name(), d.title.as_str())), Some(("equation", "Edit Equation")), "equations open the editor");
|
|
h.state_mut().dialog = None;
|
|
|
|
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:?}");
|
|
}
|