Files
wordcraft/crates/ui-egui/src/objects/tests.rs
T
ReesandBrandon Thomas a57dd81dba Copy selected objects along an axis with Ctrl+Shift (#4764) (#4775)
* #4764: copy selected objects along an axis with Ctrl+Shift

* #4775: preserve aligned copies through scrolling and secondary selection

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:14:26 -04:00

898 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::{Document, Pos};
use super::*;
/// #4764: Ctrl+Shift drags the selected object into an aligned copy, without changing
/// the original or clipboard, and the whole drop is one undo step.
#[test]
fn ctrl_shift_drag_copies_along_the_dominant_axis() {
use egui::{Event, Modifiers, PointerButton};
for (dx, dy, want_x, want_y) in [(150.0, 35.0, 350.0, 300.0), (25.0, 120.0, 200.0, 420.0)] {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 60})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 200, "y": 300})).unwrap();
app.session.clipboard_text = "untouched clipboard".into();
let before = app.session.doc.clone();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1100.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
let from = crate::canvas::page_to_screen(h.state_mut(), 0, 250.0, 330.0).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, 250.0 + dx, 330.0 + dy).unwrap();
let mods = Modifiers { shift: true, ..Modifiers::COMMAND };
h.event(Event::ModifiersChanged(mods));
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
for k in 1..=4 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
h.run_steps(1);
}
let d = h.state().canvas.obj_drag.as_ref().expect("Ctrl+Shift starts a copy drag");
assert!(d.copy);
assert!((d.drag.rect.x - want_x).abs() < 0.01 && (d.drag.rect.y - want_y).abs() < 0.01, "aligned preview: {:?}", d.drag.rect);
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(3);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
assert!(h.state().canvas.obj_drag.is_none());
assert_eq!(h.state().session.clipboard_text, "untouched clipboard");
let (pos, _) = selected(h.state()).expect("the duplicate is selected");
let layout = h.state_mut().session.layout();
let copy = layout.object(&pos, 0).unwrap();
assert!((copy.rect.x - want_x).abs() < 0.01 && (copy.rect.y - want_y).abs() < 0.01, "aligned drop: {:?}", copy.rect);
assert!(layout.find_object(0, |o| (o.rect.x - 200.0).abs() < 0.01 && (o.rect.y - 300.0).abs() < 0.01).is_some());
let after = h.state().session.doc.clone();
assert_ne!(after, before);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc, before);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(h.state().session.doc, after);
}
}
/// Guides can snap an aligned copy along its free axis, and live Shift changes or Escape
/// do not leave a stale constraint or commit a cancelled copy.
#[test]
fn ctrl_shift_drag_keeps_its_axis_with_guides_and_live_modifiers() {
use egui::{Event, Modifiers, PointerButton};
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default());
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 60})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap();
app.run("caret.docEnd", json!({})).unwrap();
app.run("insert.shape", json!({"kind": "rectangle", "width": 60, "height": 60})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 450, "y": 252})).unwrap();
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, 3)})).unwrap();
let before = app.session.doc.clone();
let depth = app.session.undo_depth();
let mut h = rotation_window(app);
let centre = h.state_mut().session.layout().pages[0].w / 2.0;
let from = crate::canvas::page_to_screen(h.state_mut(), 0, 150.0, 280.0).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, centre + 1.5, 315.0).unwrap();
let mods = Modifiers { shift: true, ..Modifiers::COMMAND };
h.event(Event::ModifiersChanged(mods));
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
for k in 1..=4 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
h.run_steps(1);
}
let d = h.state().canvas.obj_drag.as_ref().unwrap();
assert!((d.drag.rect.x + 50.0 - centre).abs() < 0.01, "the free axis still snaps");
assert!((d.drag.rect.y - 250.0).abs() < 0.01, "the nearby object's y guide must not move the copy");
assert!(d.guides.iter().all(|g| g.axis == guides::Axis::X));
assert!(!d.guides.is_empty());
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
h.run_steps(1);
assert!((h.state().canvas.obj_drag.as_ref().unwrap().drag.rect.y - 250.0).abs() > 10.0, "Shift up releases the axis");
h.event(Event::ModifiersChanged(mods));
h.run_steps(1);
assert!((h.state().canvas.obj_drag.as_ref().unwrap().drag.rect.y - 250.0).abs() < 0.01, "Shift down restores the axis");
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: mods });
h.run_steps(1);
assert!(h.state().canvas.obj_drag.is_none());
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
assert_eq!(h.state().session.doc, before);
assert_eq!(h.state().session.undo_depth(), depth);
}
/// Ctrl+Shift on another object still adds it to the selection, as do Shift and Ctrl.
#[test]
fn copy_axis_modifiers_keep_additive_object_clicks() {
use egui::{Event, Modifiers, PointerButton};
for mods in [Modifiers::SHIFT, Modifiers::COMMAND, Modifiers { shift: true, ..Modifiers::COMMAND }] {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default());
app.run("view.commentsPane", json!({"value": false})).unwrap();
for x in [100, 350] {
app.run("caret.docEnd", json!({})).unwrap();
app.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 60})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": x, "y": 250})).unwrap();
}
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, 3)})).unwrap();
let before = app.session.doc.clone();
let depth = app.session.undo_depth();
let mut h = rotation_window(app);
let at = crate::canvas::page_to_screen(h.state_mut(), 0, 400.0, 280.0).unwrap();
h.event(Event::ModifiersChanged(mods));
h.event(Event::PointerMoved(at));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
assert!(h.state().canvas.obj_drag.is_none());
assert_eq!(wordcraft_engine::cmd::objects::picked_objects(&h.state().session), vec![Pos::body(0, 0), Pos::body(0, 3)]);
assert_eq!(h.state().session.doc, before);
assert_eq!(h.state().session.undo_depth(), depth);
if mods.command && mods.shift {
h.run_steps(4);
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
h.event(Event::PointerMoved(at + egui::vec2(100.0, 20.0)));
h.run_steps(1);
assert!(h.state().canvas.obj_drag.as_ref().is_some_and(|d| d.copy), "a secondary selected object starts an aligned copy");
h.event(Event::PointerButton { pos: at + egui::vec2(100.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
assert_eq!(h.state().session.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 3);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc, before);
}
}
}
/// A middle-button scroll during an aligned copy must not move its locked document row
/// onto another page when the original page scrolls off screen.
#[test]
fn ctrl_shift_drag_keeps_its_document_axis_after_middle_scroll() {
use egui::{Event, Modifiers, PointerButton};
let text = vec!["Original long document for a scrolling copy."; 120].join("\n");
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default());
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("caret.docStart", json!({})).unwrap();
app.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 60})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap();
app.ui.alignment_guides = false;
app.canvas.auto_scroll.supported = true;
let before = app.session.doc.clone();
let mut h = rotation_window(app);
assert!(h.state_mut().session.layout().pages.len() > 2);
let from = crate::canvas::page_to_screen(h.state_mut(), 0, 150.0, 280.0).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, 450.0, 280.0).unwrap();
let mods = Modifiers { shift: true, ..Modifiers::COMMAND };
h.event(Event::ModifiersChanged(mods));
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
h.event(Event::PointerMoved(to));
h.run_steps(1);
let offset = h.state().canvas.scroll_offset.y;
h.event(Event::PointerButton { pos: to, button: PointerButton::Middle, pressed: true, modifiers: mods });
h.run_steps(1);
let below = to + egui::vec2(0.0, 150.0);
h.event(Event::PointerMoved(below));
h.run_steps(16);
assert!(h.state().canvas.scroll_offset.y > offset + 500.0, "the real middle-button gesture scrolls the canvas");
h.event(Event::PointerButton { pos: below, button: PointerButton::Middle, pressed: false, modifiers: mods });
h.run_steps(3);
let d = h.state().canvas.obj_drag.as_ref().unwrap();
assert_eq!(d.drag.to_page, 0, "the copy stays on the original horizontal row");
assert!((d.drag.rect.y - 250.0).abs() < 0.01, "the document y stays locked after scrolling");
h.event(Event::PointerButton { pos: below, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(3);
let (pos, _) = selected(h.state()).unwrap();
let hit = h.state_mut().session.layout().object(&pos, 0).unwrap();
assert_eq!(hit.page, 0);
assert!((hit.rect.y - 250.0).abs() < 0.01);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc, before);
}
/// An inline Ctrl+Shift copy follows the text insertion cursor, without an axis constraint.
#[test]
fn ctrl_shift_drag_copies_an_inline_picture_at_the_text_cursor() {
use egui::{Event, Modifiers, PointerButton};
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::from_text("First paragraph.\nSecond paragraph.")), Default::default());
app.run("view.commentsPane", json!({"value": false})).unwrap();
let image =
InlineObject::Image { media: String::new(), w: 80.0, h: 40.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
app.session.doc.insert_object(&Pos::body(1, 0), image, &Default::default()).unwrap();
app.session.touch();
app.run("select.range", json!({"anchor": Pos::body(1, 0), "focus": Pos::body(1, 3)})).unwrap();
let before = app.session.doc.clone();
let depth = app.session.undo_depth();
let mut h = rotation_window(app);
let o = h.state_mut().session.layout().object(&Pos::body(1, 0), 0).unwrap();
let target = Pos::body(0, "First".len());
let c = h.state_mut().session.layout().caret(&target).unwrap();
let from = crate::canvas::page_to_screen(h.state_mut(), 0, o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, c.x + 0.5, c.top + c.height / 2.0).unwrap();
let mods = Modifiers { shift: true, ..Modifiers::COMMAND };
h.event(Event::ModifiersChanged(mods));
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
for k in 1..=5 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 5.0)));
h.run_steps(1);
}
let d = h.state().canvas.obj_drag.as_ref().unwrap();
assert!(d.copy);
assert_eq!(d.drop_at.as_ref().map(|(p, _)| p), Some(&target));
assert!(d.guides.is_empty());
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(3);
let (pos, image) = selected(h.state()).unwrap();
assert_eq!(pos, target);
assert!(!image.is_floating());
assert_eq!(h.state().session.doc.para_at(&Pos::body(1, 0)).unwrap().objects.len(), 1, "the original remains inline");
assert_eq!(h.state().session.undo_depth(), depth + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc, before);
}
/// An app whose first paragraph holds `obj`, and where it's laid out.
fn app_with(obj: InlineObject) -> (WordApp, ObjectHit) {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.session.doc.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
(app, hit)
}
#[test]
fn charts_get_no_handles_or_drag_but_shapes_do() {
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
let (app, hit) = app_with(chart);
assert!(fixed(&app, &hit));
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 50.0,
h: 50.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let (app, hit) = app_with(shape);
assert!(!fixed(&app, &hit));
}
#[test]
fn editable_smart_art_takes_resize_handles_but_not_a_move() {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.run("insert.smartArt", json!({"layout": "pyramid", "items": ["a", "b"]})).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
assert!(!fixed(&app, &hit));
assert!(size_only(&app, &hit));
// Its body shows no move cursor; its handles show theirs.
assert_eq!(hover_cursor(&app, &hit, Grab::Move), egui::CursorIcon::Default);
assert_ne!(hover_cursor(&app, &hit, Grab::Resize(frame::Handle { x: 1, y: 0 })), egui::CursorIcon::Default);
let (w0, h0) = (hit.rect.w, hit.rect.h);
assert!(app.run("arrange.bounds", json!({"width": w0 / 2.0, "height": h0 / 2.0})).is_err());
app.run("picture.size", json!({"width": w0 / 2.0, "height": h0 / 2.0, "lockAspect": false})).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
assert!((hit.rect.w - w0 / 2.0).abs() < 1.0);
}
/// Alignment guides: dragging a floating shape to within a few points of the page's centre
/// snaps its centre exactly onto it and shows the guide while dragging; the drop lands there,
/// and the guides go. Holding Alt drags freely.
#[test]
fn dragging_a_shape_snaps_to_the_page_centre_unless_alt() {
use egui::{Event, Modifiers, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let page = h.state_mut().session.layout().pages.first().map(|p| p.w - p.markup).unwrap();
let centre = page / 2.0;
let shape = |h: &mut egui_kittest::Harness<'_, WordApp>| h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect;
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
// Press on the shape's centre and drag it so its centre ends 1.5 pt right of the page's.
let drag = |h: &mut egui_kittest::Harness<'_, WordApp>, alt: bool| {
let r = shape(h);
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
let from = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, centre + 1.5, cy).unwrap();
h.event(Event::ModifiersChanged(if alt { Modifiers::ALT } else { Modifiers::default() }));
h.event(Event::PointerMoved(from));
h.event(button(from, true));
h.step();
// Out past the drag threshold, then to the target.
for at in [from + egui::vec2(0.0, 40.0), to + egui::vec2(0.0, 40.0), to] {
h.event(Event::PointerMoved(at));
h.step();
}
let d = h.state().canvas.obj_drag.as_ref().expect("dragging");
let mid = (d.drag.rect, d.guides.clone());
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
h.event(Event::ModifiersChanged(Modifiers::default()));
h.step();
mid
};
let (rect, guides) = drag(&mut h, false);
assert!((rect.x + rect.w / 2.0 - centre).abs() < 1e-3, "snapped onto the page's centre while dragging: {rect:?}");
assert!(guides.contains(&crate::guides::Guide::x(centre)), "the centre guide shows: {guides:?}");
assert!(h.state().canvas.obj_drag.is_none(), "dropped: no guides any more");
let r = shape(&mut h);
assert!((r.x + r.w / 2.0 - centre).abs() < 0.01, "dropped on the centre: {r:?}");
// Alt: no snapping, no guides; it lands where the pointer put it.
let (rect, guides) = drag(&mut h, true);
assert!((rect.x + rect.w / 2.0 - centre - 1.5).abs() < 0.01, "free with Alt: {rect:?}");
assert!(guides.is_empty(), "{guides:?}");
let r = shape(&mut h);
assert!((r.x + r.w / 2.0 - centre - 1.5).abs() < 0.01, "{r:?}");
}
/// #1738: a floating shape dragged from page 1 onto page 2 leaves the view where it is, even
/// though the command that drops it (`arrange.bounds`) is not a view command.
#[test]
fn dropping_a_shape_on_another_page_keeps_the_view_where_it_is() {
use egui::{Event, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap();
let end = Pos::body(0, wordcraft_doc::para::OBJ.len_utf8());
a.run("caret.set", json!({"pos": end})).unwrap();
a.run("text.insert", json!({"text": "one"})).unwrap();
a.run("text.newParagraph", json!({})).unwrap();
a.run("para.pageBreakBefore", json!({})).unwrap();
a.run("text.insert", json!({"text": "two"})).unwrap();
a.run("view.zoom", json!({"value": 30})).unwrap();
a.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": end})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let r = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect;
let from = crate::canvas::page_to_screen(h.state_mut(), 0, r.x + r.w / 2.0, r.y + r.h / 2.0).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 1, 200.0, 300.0).unwrap();
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
h.event(Event::PointerMoved(from));
h.event(button(from, true));
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
h.step();
}
h.state_mut().canvas.scroll_to_caret = false;
h.event(button(to, false));
h.step();
h.step();
let landed = h.state_mut().session.layout().object_at(1, 200.0, 300.0, 0.0).is_some();
assert!(landed, "the shape landed on page 2");
assert!(!h.state().canvas.scroll_to_caret, "the drop scrolled to the caret");
}
/// A floating rectangle shape, `w` × `h`.
fn floating_rect(w: f32, h: f32) -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 10.0, y: 10.0, ..Default::default() },
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
/// A text box doesn't snap to the objects inside it (they move with it).
#[test]
fn a_text_box_does_not_snap_to_its_own_contents() {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.run("insert.textBox", json!({"text": "Box"})).unwrap();
let bx = app.session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box");
let end = Pos { off: bx.off + OBJ.len_utf8(), ..bx.pos() };
app.run("select.range", json!({"anchor": bx.pos(), "focus": end})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 200, "width": 200, "height": 150})).unwrap();
let id = app.session.layout().find_object(0, |o| o.text_box.is_some()).and_then(|o| o.text_box).unwrap();
let inner = Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 0 };
app.session.doc.insert_object(&inner, floating_rect(30.0, 20.0), &Default::default()).unwrap();
app.session.touch();
let layout = app.session.layout();
let bx = layout.find_object(0, |o| o.text_box.is_some()).unwrap();
let pg = layout.pages.first().unwrap();
let inside = layout.find_object(0, |o| o.story == StoryRef::Part(id)).expect("the box's own object is laid out");
let targets = snap_targets(pg, &bx);
assert!(!targets.contains(&inside.bounds()), "{targets:?}");
// Another floating object on the page is still a target.
let other = snap_targets(pg, &inside);
assert!(other.contains(&bx.bounds()), "{other:?}");
}
/// Pressing Alt in the middle of a drag stops the snapping, and letting it go again doesn't
/// turn keytips on (the Alt was part of the drag, not a tap).
#[test]
fn alt_pressed_during_a_drag_unsnaps_and_keeps_keytips_off() {
use egui::{Event, Modifiers, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let centre = h.state_mut().session.layout().pages.first().map(|p| (p.w - p.markup) / 2.0).unwrap();
let r = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect;
let cy = r.y + r.h / 2.0;
let from = crate::canvas::page_to_screen(h.state_mut(), 0, r.x + r.w / 2.0, cy).unwrap();
let to = crate::canvas::page_to_screen(h.state_mut(), 0, centre + 1.5, cy).unwrap();
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
h.event(Event::PointerMoved(from));
h.event(button(from, true));
h.step();
for at in [from + egui::vec2(0.0, 40.0), to] {
h.event(Event::PointerMoved(at));
h.step();
}
assert!(!h.state().canvas.obj_drag.as_ref().expect("dragging").guides.is_empty(), "snapped before Alt");
// Alt down mid-drag, as a key and a modifier change (backends send both).
let alt_key = |pressed: bool, modifiers: Modifiers| Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers };
h.event(alt_key(true, Modifiers::ALT));
h.event(Event::ModifiersChanged(Modifiers::ALT));
h.event(Event::PointerMoved(to + egui::vec2(0.0, 1.0)));
h.step();
let d = h.state().canvas.obj_drag.as_ref().expect("still dragging");
assert!(d.guides.is_empty() && (d.drag.rect.x + d.drag.rect.w / 2.0 - centre - 1.5).abs() < 0.01, "free with Alt");
// Alt up, then the release.
h.event(alt_key(false, Modifiers::default()));
h.event(Event::ModifiersChanged(Modifiers::default()));
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
assert!(h.state().canvas.obj_drag.is_none());
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off, "Alt during a drag isn't a keytip tap");
}
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a dashed
/// insertion cursor at the text under the pointer (no alignment guides), and the drop puts the
/// picture there, still inline and selected. Dropped back where it was, nothing changes.
#[test]
fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
use crate::table_resize::tests::{H, button, steps};
let mut a = WordApp::new(
wordcraft_engine::Session::new(Document::from_text("The first paragraph of text.\nThe second paragraph, where the picture starts.")),
Default::default(),
);
a.run("view.commentsPane", json!({"value": false})).unwrap();
let picture =
InlineObject::Image { media: String::new(), w: 80.0, h: 40.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
a.session.doc.insert_object(&Pos::body(1, 0), picture, &Default::default()).unwrap();
a.session.touch();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
steps(&mut h, 6);
let o = h.state_mut().session.layout().object(&Pos::body(1, 0), 0).expect("laid out");
assert_eq!(o.wrap, Wrap::Inline);
// Aim between "first" and " paragraph" in the first paragraph.
let target = Pos::body(0, "The first".len());
let c = h.state_mut().session.layout().caret(&target).expect("caret");
let at = |h: &mut H, x: f32, y: f32| crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap();
let start = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0);
let end = at(&mut h, c.x + 0.5, c.top + c.height / 2.0);
h.event(egui::Event::PointerMoved(start));
h.step();
button(&h, start, true);
h.step();
for k in 1..=5 {
h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0)));
h.step();
}
let d = h.state().canvas.obj_drag.as_ref().expect("dragging");
assert_eq!(d.drop_at.as_ref().map(|(p, _)| p.clone()), Some(target.clone()), "the insertion cursor is at the text under the pointer");
assert!(d.guides.is_empty(), "an inline object doesn't snap to guides");
button(&h, end, false);
steps(&mut h, 3);
assert!(h.state().canvas.obj_drag.is_none());
let (pos, obj) = selected(h.state()).expect("still selected");
assert_eq!(pos, target, "landed at the insertion cursor");
assert!(!obj.is_floating(), "still In Line with Text");
assert_eq!(h.state().session.doc.para_at(&Pos::body(1, 0)).map(|p| p.objects.len()), Some(0), "left its old place");
// Back onto itself: no change, no undo step.
let undo = h.state().session.undo_depth();
let o = h.state_mut().session.layout().object(&target, 0).expect("laid out");
let start = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0);
button(&h, start, true);
h.step();
for v in [egui::vec2(12.0, 0.0), egui::vec2(0.0, 0.0)] {
h.event(egui::Event::PointerMoved(start + v));
h.step();
}
button(&h, start, false);
steps(&mut h, 3);
assert_eq!(selected(h.state()).map(|(p, _)| p), Some(target));
assert_eq!(h.state().session.undo_depth(), undo);
}
/// #1010: dragging a shape off text, the preview is the shape alone, not the text that was
/// behind it. An unfilled red rectangle in front of black text is dragged down into the empty
/// page: its preview has its red outline and nothing dark inside.
#[test]
fn a_dragged_shape_leaves_the_text_behind_it() {
use crate::table_resize::tests::{H, button, steps};
let text = "MMMMMMMMMMMM ".repeat(12);
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default());
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 120.0,
h: 60.0,
fill: None,
stroke: Some(wordcraft_doc::Rgb(0xFF, 0, 0)),
stroke_width: 3.0,
float: wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::InFrontOfText, ..Default::default() },
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
a.session.doc.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
steps(&mut h, 6);
let o = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
let at = |h: &mut H, x: f32, y: f32| crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap();
let down = 200.0;
let start = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + 4.0);
let end = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + 4.0 + down);
h.event(egui::Event::PointerMoved(start));
h.step();
button(&h, start, true);
h.step();
for k in 1..=5 {
h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0)));
h.step();
}
h.step(); // the preview is painted a frame after the move
assert!(h.state().canvas.obj_drag.as_ref().is_some_and(|d| d.drag.moved), "a drag is in progress");
let (tl, br) = (at(&mut h, o.rect.x, o.rect.y + down), at(&mut h, o.rect.right(), o.rect.bottom() + down));
let img = h.render().unwrap();
let ppp = h.ctx.pixels_per_point();
// Pixels in a screen area, but for the mouse pointer drawn at `end`.
let count = |x0: f32, y0: f32, x1: f32, y1: f32, f: &dyn Fn([u8; 4]) -> bool| {
let (x0, y0, x1, y1) = ((x0 * ppp).ceil() as u32, (y0 * ppp).ceil() as u32, (x1 * ppp) as u32, (y1 * ppp) as u32);
let pointer = Rect::from_min_size(end - egui::vec2(2.0, 2.0), egui::vec2(20.0, 26.0));
(y0..y1)
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
.filter(|&(x, y)| !pointer.contains(egui::pos2(x as f32 / ppp, y as f32 / ppp)) && f(img.get_pixel(x, y).0))
.count()
};
let red = count(tl.x - 4.0, tl.y - 4.0, br.x + 4.0, br.y + 4.0, &|[r, g, b, _]| r > 200 && g < 180 && b < 180);
assert!(red > 50, "the preview shows the shape's outline ({red} red pixels)");
let dark = count(tl.x + 8.0, tl.y + 8.0, br.x - 8.0, br.y - 8.0, &|[r, g, b, _]| r < 180 && g < 180 && b < 180);
assert_eq!(dark, 0, "no text from behind the shape in its preview");
button(&h, end, false);
steps(&mut h, 3);
}
/// A 100×60 pt rectangle filled E2EFDA: [`fill_pixels`] counts the page's pixels of that colour.
fn filled_shape() -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 100.0,
h: 60.0,
fill: Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::props::Rgb(0xE2, 0xEF, 0xDA))),
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
/// How many pixels of the first page have the fill colour: how many copies of the shape the
/// page shows.
fn fill_pixels(app: &mut WordApp, opts: &wordcraft_render::RenderOptions) -> usize {
let layout = app.session.layout();
let img = wordcraft_render::render_page(&app.session.doc, &layout.pages[0], 1.0, opts);
img.to_straight().as_chunks::<4>().0.iter().filter(|p| (p[0], p[1], p[2]) == (0xE2, 0xEF, 0xDA)).count()
}
/// A shape dragged on the page is left out of it: the preview under the pointer stands in for
/// it, so the page must not draw it where it was as well (`DisplayOptions::hide_object`).
///
/// Judged by pixels: the page raster has none of its fill colour once it is left out, and a
/// thousand or so while it isn't. Without this, "it stays where it was while it is dragged"
/// would come back with nothing to catch it.
#[test]
fn a_dragged_inline_object_is_left_out_of_its_page() {
let (mut app, hit) = app_with(filled_shape());
let pos = hit.pos();
let on = wordcraft_render::RenderOptions::default();
let before = fill_pixels(&mut app, &on);
assert!(before > 1000, "the shape is on its page to begin with: {before} px");
let mut hidden = on.clone();
hidden.display.hide_object = Some(pos);
assert_eq!(fill_pixels(&mut app, &hidden), 0, "a dragged object is left out of its page");
}
/// The same for an object that is already floating: it is drawn as a `Placed::Shape` of its
/// own rather than inside a line, so leaving it out goes by the object the shape carries.
#[test]
fn a_dragged_floating_shape_is_left_out_of_its_page() {
let (mut app, hit) = app_with(filled_shape());
let pos = hit.pos();
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", serde_json::json!({"anchor": pos, "focus": end}));
app.run("arrange.bounds", serde_json::json!({"page": 0, "x": 100.0, "y": 150.0})).expect("floats it");
let hit = app.session.layout().object(&pos, 0).expect("laid out");
assert!(hit.floating(), "arrange.bounds makes it float");
let on = wordcraft_render::RenderOptions::default();
let before = fill_pixels(&mut app, &on);
assert!(before > 1000, "the floating shape is on its page to begin with: {before} px");
let mut hidden = on.clone();
hidden.display.hide_object = Some(pos);
assert_eq!(fill_pixels(&mut app, &hidden), 0, "a dragged floating shape is left out of its page");
}
/// A floating shape dragged onto the next page lands there — the page the preview shows —
/// even dropped at the very top of it, where the anchor is the page's first paragraph. The
/// shape's wrap holds that paragraph on the old page, so once the shape moves the paragraph
/// can slide back and take the shape with it; the drop must re-anchor until it stays.
#[test]
fn a_shape_dragged_onto_the_next_page_is_dropped_there() {
use egui::{Event, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
for i in 0..40 {
if i > 0 {
a.run("text.newParagraph", json!({})).unwrap();
}
a.run("text.insert", json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of the page.")})).unwrap();
}
a.run("caret.docStart", json!({})).unwrap();
a.run("insert.shape", json!({"kind": "rectangle"})).unwrap();
a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
assert!(a.session.layout().pages.len() > 1, "a second page to drag onto");
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let shape =
|h: &mut egui_kittest::Harness<'_, WordApp>| h.state_mut().session.layout().find_object(0, |o| o.text_box.is_none()).expect("a shape");
let r = shape(&mut h).rect;
let from = crate::canvas::page_to_screen(h.state_mut(), 0, r.x + r.w / 2.0, r.y + r.h / 2.0).expect("the shape's page is on screen");
let to = crate::canvas::page_to_screen(h.state_mut(), 1, 150.0, 120.0).expect("the next page is on screen");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
h.event(Event::PointerMoved(from));
h.event(button(from, true));
h.step();
for at in [from + egui::vec2(0.0, 40.0), to] {
h.event(Event::PointerMoved(at));
h.step();
}
assert_eq!(h.state().canvas.obj_drag.as_ref().expect("dragging").drag.to_page, 1, "the preview is on the page under the pointer");
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
let o = shape(&mut h);
assert_eq!(o.page, 1, "dropped on the page under the pointer, not thrown back");
assert!((o.rect.x - 100.0).abs() < 0.5, "kept its place across: {:?}", o.rect);
}
/// A settled canvas for the rotation regressions, without taking focus on the desktop.
fn rotation_window(app: WordApp) -> crate::table_resize::tests::H {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
crate::table_resize::tests::steps(&mut h, 6);
h
}
/// #2277: the actual picture pixels follow the rotation handle before release, including
/// pictures whose cached preview already contains a rotation and either reflection.
#[test]
fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() {
use crate::table_resize::tests::{H, button, steps};
for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] {
// Four original colour quadrants make both accidental extra rotation and reflected
// UVs observable. Sample near the long ends after a quarter turn, outside the old
// picture, so the stationary page image cannot satisfy the pixel assertions.
let colours = [[230, 20, 20, 255], [20, 200, 20, 255], [20, 20, 230, 255], [230, 200, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| {
let quadrant = match (x < 40, y < 20) {
(true, true) => 0,
(false, true) => 1,
(false, false) => 2,
(true, false) => 3,
};
image::Rgba(colours[quadrant])
});
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
let picture = InlineObject::Image {
media: "rotation-test".into(),
w: 200.0,
h: 100.0,
alt: String::new(),
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 150.0, y: 180.0, rot: initial, flip_h, flip_v, ..Default::default() },
crop: [0.0; 4],
ole: None,
};
let (mut app, _) = app_with(picture);
app.session.doc.media.insert("rotation-test".into(), Arc::new(bytes));
app.session.touch();
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap();
let mut h: H = rotation_window(app);
let depth = h.state().session.undo_depth();
let original = serde_json::to_value(&h.state().session.doc).unwrap();
let o = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap();
let (cx, cy) = (o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0);
let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap();
let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, o.rect.y).unwrap();
let radius = center.distance(top) + 22.0;
let handle = |degrees: f32| center + egui::emath::Rot2::from_angle(degrees.to_radians()) * egui::vec2(0.0, -radius);
let target = wordcraft_geom::normalize_degrees(initial + 90.0);
let start = handle(initial);
let end = handle(target);
h.event(egui::Event::PointerMoved(start));
button(&h, start, true);
h.step();
h.event(egui::Event::PointerMoved(end));
steps(&mut h, 2);
let d = h.state().canvas.obj_drag.as_ref().expect("rotating");
assert_eq!(d.drag.grab, Grab::Rotate);
assert_eq!(d.drag.deg, target);
let texture = d.preview.as_ref().expect("cached picture preview").id();
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "dragging must not edit the document");
assert_eq!(h.state().session.undo_depth(), depth);
let rendered = h.render().unwrap();
if initial == 0.0
&& let Ok(path) = std::env::var("WORDCRAFT_ROTATION_SHOT")
{
rendered.save(path).unwrap();
}
let ppp = h.ctx.pixels_per_point();
let spin = wordcraft_geom::Spin::new(target, flip_h, flip_v);
for ((x, y), colour) in [(-75.0, -20.0), (75.0, -20.0), (75.0, 20.0), (-75.0, 20.0)].into_iter().zip(colours) {
let (x, y) = spin.apply(cx, cy, cx + x, cy + y);
let at = crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap();
let pixel = rendered.get_pixel((at.x * ppp).round() as u32, (at.y * ppp).round() as u32).0;
for (actual, expected) in pixel.into_iter().zip(colour).take(3) {
if expected > 150 {
assert!(actual > 170, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}");
} else {
assert!(actual < 150, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}");
}
}
}
// A second angle reuses the raster rather than regenerating it on pointer moves.
let next = handle(target + 15.0);
h.event(egui::Event::PointerMoved(next));
steps(&mut h, 2);
assert_eq!(h.state().canvas.obj_drag.as_ref().unwrap().preview.as_ref().unwrap().id(), texture);
let final_angle = h.state().canvas.obj_drag.as_ref().unwrap().drag.deg;
button(&h, next, false);
steps(&mut h, 3);
assert!(h.state().canvas.obj_drag.is_none());
assert_eq!(h.state().session.undo_depth(), depth + 1, "one command on release");
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, final_angle);
h.state_mut().run("edit.undo", json!({})).unwrap();
steps(&mut h, 3);
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "one undo restores everything");
h.event(egui::Event::PointerMoved(start));
button(&h, start, true);
h.step();
h.event(egui::Event::PointerMoved(end));
steps(&mut h, 2);
assert!(h.state().canvas.obj_drag.as_ref().unwrap().preview.is_some());
h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.step();
button(&h, end, false);
steps(&mut h, 3);
assert!(h.state().canvas.obj_drag.is_none());
assert_eq!(h.state().session.undo_depth(), depth, "Escape adds no undo step");
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "Escape cancels without an edit");
}
}
#[test]
fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() {
use crate::table_resize::tests::{button, steps};
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default());
app.run("insert.textBox", json!({"text": "Text stays upright on release"})).unwrap();
let bx = app.session.layout().find_object(0, |o| o.text_box.is_some()).unwrap();
app.run("select.range", json!({"anchor": bx.pos(), "focus": Pos { off: bx.off + OBJ.len_utf8(), ..bx.pos() }})).unwrap();
app.run("arrange.bounds", json!({"page": 0, "x": 150, "y": 180, "width": 200, "height": 100})).unwrap();
let mut h = rotation_window(app);
let original = serde_json::to_value(&h.state().session.doc).unwrap();
let depth = h.state().session.undo_depth();
let bx = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).unwrap();
let (cx, cy) = (bx.rect.x + bx.rect.w / 2.0, bx.rect.y + bx.rect.h / 2.0);
let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap();
let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, bx.rect.y).unwrap();
let radius = center.distance(top) + 22.0;
let (start, end) = (center - egui::vec2(0.0, radius), center + egui::vec2(radius, 0.0));
h.event(egui::Event::PointerMoved(start));
button(&h, start, true);
h.step();
h.event(egui::Event::PointerMoved(end));
steps(&mut h, 2);
let d = h.state().canvas.obj_drag.as_ref().expect("turning the text box frame");
assert_eq!((d.drag.grab, d.drag.deg), (Grab::Rotate, 90.0));
assert!(d.preview.is_none(), "do not preview rotated text that would snap upright on release");
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original);
assert_eq!(h.state().session.undo_depth(), depth);
button(&h, end, false);
steps(&mut h, 3);
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0);
assert_eq!(h.state().session.undo_depth(), depth + 1);
}