Files
wordcraft/crates/ui-egui/src/objects.rs
T
aiongg 04b3881e3e Integration: track t06
docs/gaps.md row 4: one row (t06 had kept a second, older copy of it); t05's #1840 and t06's #2964 clauses both in, main's #2963 clause kept. docs/recovery.md: t06's retry sentence with t01's base64 size text. Test files: both tracks' tests kept whole.
2026-10-22 01:47:34 +00:00

1392 lines
70 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.
//! Pictures, shapes and text boxes on the canvas: click one to select it, drag it to move it,
//! drag a handle to resize it, drag the rotation handle to turn it (Shift: 15° steps), arrow keys
//! nudge a floating one. The frame itself is [`crate::frame`]; changes go through
//! `arrange.bounds` / `arrange.rotation` / `arrange.nudge`, so they're undoable and scriptable like
//! everything else.
//!
//! While dragging, nothing is laid out: the preview is the object drawn alone, once, when the
//! drag starts (#1010: a cut of the page would carry along whatever is behind it). The page it
//! was on is drawn again without it (see [`dragged_off_page`]), so it is not left behind where it
//! was. The drop is one command, one relayout.
//!
//! Dragging an In Line with Text object moves it within the text, as in Word: a dashed insertion
//! cursor follows the pointer through the text (#2233) and the drop puts it there, still inline
//! (`arrange.bounds` / `arrange.copyTo` with `to`, #2234). A floating one goes where it's dropped.
//!
//! Moving a floating one snaps it to alignment guides ([`crate::guides`]): the page's edges and
//! centre, the margins and the other floating objects on the page, shown as thin lines while
//! dragging. Alt moves freely; File › Options › Advanced turns them off (`ui.alignmentGuides`).
//!
//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape
//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free.
use egui::{Color32, Painter, Pos2, Rect, Ui};
use serde_json::json;
use wordcraft_doc::para::{InlineObject, OBJ, Wrap};
use wordcraft_doc::{PartKind, Pos, StoryRef};
use wordcraft_layout::DocLayout;
use wordcraft_layout::hit::ObjectHit;
use crate::WordApp;
use crate::frame::{self, Drag, Grab};
use crate::guides;
use crate::theme::Tokens;
/// The band along a text box's border that grabs the box rather than its text, screen points.
const EDGE: f32 = 5.0;
/// How opaque the drag preview of an object's pixels is (0–255).
const PREVIEW_OPACITY: u8 = 170;
/// Arrow-key nudge, points (Ctrl/Alt: fine).
const NUDGE: f32 = 6.0;
const NUDGE_FINE: f32 = 1.0;
/// An object being dragged by its frame.
pub struct ObjectDrag {
object: ObjectHit,
drag: Drag,
/// Smallest size it can be resized to, points.
min: f32,
picture: bool,
/// A selected Behind Text picture overlaps text; wait for a drag threshold before taking the
/// gesture so a simple click can still put the caret in that text.
pending_behind: bool,
/// Dragging a connector's end (false: its start, true: its end) to this page point.
end: Option<(bool, (f32, f32))>,
/// Ctrl/⌘ is held: dropping leaves the original and puts a copy at the new place.
copy: bool,
/// The alignment guides a move snapped to, shown while dragging ([`crate::guides`]).
guides: Vec<crate::guides::Guide>,
/// The object drawn alone over its rotated bounds, for the preview: made once it moves.
preview: Option<egui::TextureHandle>,
/// Moving an In Line with Text object: the text position under the pointer, where it lands,
/// and the colour of the dashed insertion cursor drawn there.
drop_at: Option<(Pos, Color32)>,
}
/// How near (screen points) a press must be to a connector's end to grab it.
const END_GRAB: f32 = 7.0;
/// The selected connector's start and end on its page (points), and its layout.
fn connector_ends(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, (f32, f32), (f32, f32))> {
let (pos, InlineObject::Shape { kind, float, .. }) = selected(app)? else { return None };
if !kind.is_connector() {
return None;
}
let (flip_h, flip_v) = (float.flip_h, float.flip_v);
let o = layout.object(&pos, app.session.page_hint)?;
let (a, b) = wordcraft_doc::connector::ends(o.rect.x, o.rect.y, o.rect.w, o.rect.h, flip_h, flip_v);
Some((o, a, b))
}
fn point_on_screen(pages: &[Rect], scale: f32, page: usize, (x, y): (f32, f32)) -> Option<Pos2> {
pages.get(page).map(|p| egui::pos2(p.min.x + x * scale, p.min.y + y * scale))
}
/// The selected connector's end under `at` (screen): which (false: start), and the connector.
fn connector_end_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, bool)> {
let (o, a, b) = connector_ends(app, layout)?;
let near = |p: (f32, f32)| point_on_screen(pages, scale, o.page, p).is_some_and(|s| s.distance(at) <= END_GRAB);
if near(b) {
Some((o, true))
} else if near(a) {
Some((o, false))
} else {
None
}
}
/// The selected object: the selection is exactly one picture, shape or text box, in the body
/// or in a header/footer.
pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> {
wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| selectable_story(app, p.story))
}
/// Whether objects of `story` can be selected and dragged on the canvas: the body and
/// headers/footers.
fn selectable_story(app: &WordApp, story: StoryRef) -> bool {
match story {
StoryRef::Body => true,
StoryRef::Part(id) => app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)),
}
}
/// The story whose objects the canvas handles: the header/footer being edited, else the body.
fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
let story = crate::canvas::text_box_host(app, layout).unwrap_or(app.session.sel.focus.story);
match story {
StoryRef::Body => StoryRef::Body,
StoryRef::Part(id) if selectable_story(app, StoryRef::Part(id)) => StoryRef::Part(id),
// Notes and text-box text: no top-level objects live here, so grabs find nothing and
// the press falls through to text handling.
other => other,
}
}
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
/// and deleted, not moved or resized (they aren't written back on save yet), so they get no
/// handles, no move cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. }))
}
/// The story of the selected text box.
fn selected_text_box(app: &WordApp) -> Option<u32> {
match selected(app)? {
(_, InlineObject::Shape { story: Some(id), .. }) if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
}
}
/// The object showing a frame, and whether its text is being edited (caret in a text box).
pub fn active(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, bool)> {
let hint = app.session.page_hint;
if let Some((pos, _)) = selected(app) {
return layout.object(&pos, hint).map(|o| (o, false));
}
match app.session.sel.focus.story {
StoryRef::Part(id) if crate::canvas::in_text_box(app) => layout.text_box(id, hint).map(|o| (o, true)),
_ => None,
}
}
fn screen(pages: &[Rect], scale: f32, page: usize, r: wordcraft_geom::Rect) -> Option<Rect> {
pages.get(page).map(|p| frame::to_screen(*p, scale, r))
}
/// Pressing, dragging, releasing or double-clicking on an object (or a frame handle). Returns
/// true when it was about an object, so the text doesn't also get it.
pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect], layout: &DocLayout, scale: f32, at: Pos2) -> bool {
if let Some(taken) = crate::crop::pointer(app, ui, resp, pages, layout, scale, at) {
return taken;
}
if let Some(mut d) = app.canvas.obj_drag.take() {
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
return true; // cancelled: dropped without a change
}
if d.pending_behind {
if !ui.input(|i| i.pointer.primary_down()) {
// No drag threshold was crossed: let the text click finish normally.
app.canvas.cancel_text_drag();
return false;
}
let shift = ui.input(|i| i.modifiers.shift);
d.drag.update(at, pages, scale, d.min, d.picture != shift);
if !d.drag.moved {
app.canvas.obj_drag = Some(d);
return false;
}
let pos = d.object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
// The text click path has been tracking the pointer until it became a drag.
app.canvas.cancel_text_drag();
d.pending_behind = false;
}
if let Some((which, _)) = d.end {
if ui.input(|i| i.pointer.primary_down()) {
if let Some((_, x, y)) = crate::canvas::page_at(pages, layout, scale, at).filter(|(p, ..)| *p == d.object.page) {
d.end = Some((which, (x, y)));
d.drag.moved = true;
}
app.canvas.obj_drag = Some(d);
} else if d.drag.moved
&& let Some((_, (x, y))) = d.end
{
let key = if which { "end" } else { "start" };
let _ = app.run("shape.connect", json!({key: {"x": x, "y": y, "page": d.object.page}}));
}
return true;
}
if ui.input(|i| i.pointer.primary_down()) {
let shift = ui.input(|i| i.modifiers.shift);
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move;
if d.drag.grab == Grab::Rotate {
d.drag.turn_to(at, pages, scale, shift);
} else {
// Pictures keep their proportions from a corner unless Shift; shapes only with Shift.
d.drag.update(at, pages, scale, d.min, d.picture != shift);
// Alignment guides: a move snaps to the page, the margins and the other objects
// (Alt: freely).
d.guides.clear();
if d.drag.grab == Grab::Move && d.drag.moved && !d.object.floating() {
// An inline one lands in the text: the insertion cursor follows the pointer.
d.drop_at = drop_point(app, layout, pages, scale, at, &d);
} else if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) {
snap_move(layout, &mut d, scale);
}
}
if d.drag.moved && d.preview.is_none() && shows_pixels(&d) {
d.preview = preview(app, ui.ctx(), layout, &d.object, scale);
}
app.canvas.obj_drag = Some(d);
} else {
drop(app, &d);
}
return true;
}
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
if app.canvas.link_from.is_some() && (pressed || ui.input(|i| i.key_pressed(egui::Key::Escape))) {
link_click(app, layout, pages, scale, at, pressed);
return true;
}
let multi = resp.double_clicked() || resp.triple_clicked();
if !(pressed || multi) {
return false;
}
// A selected connector's end: drag it to glue it.
if pressed && let Some((object, which)) = connector_end_at(app, layout, pages, scale, at) {
let drag = Drag::new(Grab::Move, object.page, object.rect, 0.0, at);
app.canvas.obj_drag = Some(ObjectDrag {
drag,
object,
min: 0.0,
picture: false,
pending_behind: false,
end: Some((which, (0.0, 0.0))),
guides: Vec::new(),
preview: None,
copy: false,
drop_at: None,
});
return true;
}
let grabbed = grab_at(app, layout, pages, scale, at).or_else(|| pressed.then(|| behind_drag_at(app, layout, pages, scale, at)).flatten());
let Some((object, grab)) = grabbed else { return false };
// Double-click a SmartArt graphic (selected by the first click) to edit its text.
if resp.double_clicked() && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() {
let _ = app.run("smartArt.textPane", json!({"value": true}));
return true;
}
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
// Ctrl on the object that is already selected starts a copy-drag instead.
let again = selected(app).is_some_and(|(p, _)| p == object.pos());
if pressed && adding && grab == Grab::Move && selected(app).is_some() && !(again && !ui.input(|i| i.modifiers.shift)) {
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
return true;
}
if pressed && grab == Grab::Move && object.behind() && behind_draggable(app, &object) {
let (min, picture) = match app.session.doc.para_at(&object.pos()).and_then(|p| p.object_at(object.pos().off)) {
Some(o) => (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. })),
None => return false,
};
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
object,
min,
picture,
pending_behind: true,
end: None,
guides: Vec::new(),
preview: None,
copy: false,
drop_at: None,
});
return false;
}
if pressed {
let pos = object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
let (min, picture) = match selected(app) {
Some((_, o)) => (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. })),
None => return true,
};
// Objects in table cells can be selected and resized, not moved; charts neither.
if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) {
let copy = grab == Grab::Move && ui.input(|i| i.modifiers.command);
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
object,
min,
picture,
pending_behind: false,
end: None,
guides: Vec::new(),
preview: None,
copy,
drop_at: None,
});
}
}
true
}
/// The object a move has taken off its page (which page, and where it is in the document): the
/// page is rendered again without it while the preview follows the pointer, so the object is not
/// left behind where it was. Only for a move — resizing and turning show the object itself, and
/// dragging a connector's end onto a shape still shows that connector.
pub(crate) fn dragged_off_page(drag: Option<&ObjectDrag>) -> Option<(usize, Pos)> {
let d = drag?;
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move {
return None;
}
Some((d.object.page, d.object.pos()))
}
/// Where an In Line with Text object dragged to `at` (screen) lands: the nearest position of its
/// own story's text there, with the insertion cursor's colour (the same contrast as the caret's).
fn drop_point(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, d: &ObjectDrag) -> Option<(Pos, Color32)> {
let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?;
let pos = layout.hit(page, x, y, d.object.story)?;
if let Some((was, color)) = &d.drop_at
&& *was == pos
{
return Some((pos, *color)); // the paper under it is worked out once per position
}
let c = layout.caret_on(&pos, page)?;
Some((pos, crate::canvas::caret_color_on(app, layout, c.page, c.x, c.top + c.height / 2.0)))
}
/// Snap a moved object's bounds (rotated ones by their turned box) to the guides of the page
/// it's over: the page's edges and centre, its margins, the other floating objects there.
fn snap_move(layout: &DocLayout, d: &mut ObjectDrag, scale: f32) {
let Some(pg) = layout.pages.get(d.drag.to_page) else { return };
let candidates = guides::candidates(paper_width(pg), pg.h, pg.body, snap_targets(pg, &d.object));
let s = guides::snap(d.object.spin.bounds(d.drag.rect), &candidates, guides::SNAP_SCREEN / scale.max(0.01));
d.drag.rect.x += s.dx;
d.drag.rect.y += s.dy;
d.guides = s.lines;
}
/// The page's own width, without the comment balloons' markup area.
fn paper_width(pg: &wordcraft_layout::Page) -> f32 {
(pg.w - pg.markup).max(0.0)
}
/// The bounds of the floating objects on `pg` that `me` can snap to: all but itself and, for a
/// text box, the objects in its text (they move with it).
fn snap_targets(pg: &wordcraft_layout::Page, me: &ObjectHit) -> Vec<wordcraft_geom::Rect> {
let pos = me.pos();
let own = me.text_box.map(StoryRef::Part);
pg.items
.iter()
.chain(&pg.header)
.chain(&pg.footer)
.filter_map(|it| match it {
wordcraft_layout::Placed::Object { rect, spin, story, path, off, wrap, .. }
if *wrap != Wrap::Inline && !(*story == pos.story && *path == pos.path && *off == pos.off) && Some(*story) != own =>
{
Some(spin.bounds(*rect))
}
_ => None,
})
.collect()
}
/// The guides a move snapped to: thin lines across the page (not into the balloon area).
fn paint_guides(d: &ObjectDrag, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) {
let Some(p) = pages.get(d.drag.to_page) else { return };
let right = layout.pages.get(d.drag.to_page).map_or(p.max.x, |pg| (p.min.x + paper_width(pg) * scale).min(p.max.x));
let stroke = egui::Stroke::new(1.0, t.guide);
for g in &d.guides {
let line = match g.axis {
guides::Axis::X => [egui::pos2(p.min.x + g.at * scale, p.min.y), egui::pos2(p.min.x + g.at * scale, p.max.y)],
guides::Axis::Y => [egui::pos2(p.min.x, p.min.y + g.at * scale), egui::pos2(right, p.min.y + g.at * scale)],
};
painter.line_segment(line, stroke);
}
}
/// Create Link's click: link the box it started from to the text box under `at` (anywhere on it),
/// if `pressed`; either way, linking is over.
fn link_click(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, pressed: bool) {
let Some(from) = app.canvas.link_from.take() else { return };
if !pressed {
return;
}
let target = crate::canvas::page_at(pages, layout, scale, at).and_then(|(page, x, y)| {
layout.find_object(page, |o| o.page == page && o.text_box.is_some() && o.rect.contains(wordcraft_geom::Point::new(x, y)))
});
let Some(target) = target else {
app.status(tl!("Not linked: click a text box"));
return;
};
let end = Pos { off: from.off + OBJ.len_utf8(), ..from.clone() };
let _ = app.run("select.range", json!({"anchor": from, "focus": end}));
if let Err(e) = app.run("shape.link", json!({"target": target.pos()})) {
app.status(e);
}
}
/// Right-click at `at`: the picture, shape or text box under it becomes the selection (as a click
/// would, without a drag), so the context menu acts on it. Returns whether there was one.
pub fn select_at(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> bool {
if crate::canvas::editing_header_footer(app, layout) {
return false;
}
let Some((object, _)) = grab_at(app, layout, pages, scale, at) else { return false };
let pos = object.pos();
// Already selected (alone or among several picked for Group): keep the selection.
if !wordcraft_engine::cmd::objects::picked_objects(&app.session).contains(&pos) {
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
}
true
}
/// What a press at `at` would grab: a handle of the shown frame, else an object of the story
/// being edited (body, or the header/footer).
fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> {
let story = active_story(app, layout);
let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| {
let f = screen(pages, scale, o.page, o.rect)?;
if frame::rotate_handle_at(f, o.spin.deg, at) {
return Some((o, Grab::Rotate));
}
frame::handle_at(f, o.spin.deg, at).map(|h| (o, Grab::Resize(h)))
});
handle.or_else(|| {
let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?;
// A Behind Text picture is normally hit only where no body text is present, so text above
// it remains easy to edit. Once the picture is selected (for example with the arrow keys
// or the Selection Pane), let its own frame win inside the frame so the user can drag it
// directly without first finding an uncovered patch.
let selected_picture = selected(app)
.filter(|(_, obj)| behind_body_drag(obj))
.and_then(|(pos, _)| layout.object(&pos, app.session.page_hint))
.filter(|o| o.behind() && o.page == page && movable(app, o) && o.rect.expand(EDGE / scale.max(0.01)).contains(o.unspin(x, y)))
.map(|o| (o, Grab::Move));
if selected_picture.is_some() {
return selected_picture;
}
// Selectable: body objects (table cells too) or the header/footer's own objects;
// notes' objects aren't yet.
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
})
}
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
/// header/footer.
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
o.path.depth() == 0 && selectable_story(app, o.story)
}
/// Whether a Behind Text object of this kind is grabbed by pressing its body. Pictures, shapes and
/// groups yes; a text box no (its inside is its text, so only its border grabs it). Used so one can
/// be dragged over the text it sits behind.
fn behind_body_drag(obj: &InlineObject) -> bool {
matches!(obj, InlineObject::Image { .. } | InlineObject::Group { .. } | InlineObject::Shape { story: None, .. })
}
/// Whether the object at `o` is a Behind Text picture, shape or group that grabbing its body moves.
fn behind_draggable(app: &WordApp, o: &ObjectHit) -> bool {
app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.pos().off)).is_some_and(behind_body_drag)
}
/// The topmost Behind Text picture, shape or group of the story being edited under `at`, even
/// where text covers it. A press over one couldn't grab it before (text won), so a drag did
/// nothing; with this it starts a frame drag once the pointer moves, while a plain click still
/// edits the text it sits behind (see `pending_behind`).
fn behind_drag_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> {
let story = active_story(app, layout);
let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?;
let edge = EDGE / scale.max(0.01);
layout
.find_object(page, |o| {
o.page == page
&& o.story == story
&& o.behind()
&& movable(app, o)
&& behind_draggable(app, o)
&& o.rect.expand(edge).contains(o.unspin(x, y))
})
.map(|o| (o, Grab::Move))
}
/// Whether a drag shows the object's pixels rather than an outline. Resizing reflows a text
/// box (and redraws a shape). Text-box text doesn't yet follow its frame's committed rotation,
/// so turning one keeps the outline preview rather than promising rotated text on release.
fn shows_pixels(d: &ObjectDrag) -> bool {
(d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none())
}
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
/// per page point).
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?;
let page = layout.pages.get(o.page)?;
let mut opts = crate::canvas::screen_render_options();
opts.dark = app.session.view.dark_mode;
let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display);
let b = o.bounds();
let img = wordcraft_render::render_items(&app.session.doc, &items, b.x, b.y, b.w, b.h, scale * ctx.pixels_per_point(), &opts);
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
Some(ctx.load_texture("object-drag", ci, egui::TextureOptions::LINEAR))
}
/// Commit a finished drag as one command.
fn drop(app: &mut WordApp, d: &ObjectDrag) {
if !d.drag.moved {
return;
}
let r = d.drag.rect;
let params = match d.drag.grab {
Grab::Rotate => {
let _ = app.run("arrange.rotation", json!({"degrees": d.drag.deg}));
return;
}
// An In Line with Text object stays in the text, at the insertion cursor; dropped on
// itself, it doesn't move.
Grab::Move if !d.object.floating() => {
let Some((to, _)) = &d.drop_at else { return };
let pos = d.object.pos();
let on_itself = to.story == pos.story && to.path == pos.path && (to.off == pos.off || to.off == pos.off + OBJ.len_utf8());
if d.copy {
let _ = app.run("arrange.copyTo", json!({"to": to}));
} else if !on_itself {
let _ = app.run("arrange.bounds", json!({"to": to}));
}
return;
}
Grab::Move if d.copy => {
let _ = app.run("arrange.copyTo", json!({"page": d.drag.to_page, "x": r.x, "y": r.y}));
return;
}
Grab::Move => json!({"page": d.drag.to_page, "x": r.x, "y": r.y}),
// An inline object stays in the text; a floating one keeps its far edges put.
Grab::Resize(_) if d.object.floating() && movable(app, &d.object) => {
json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y})
}
Grab::Resize(_) => json!({"width": r.w, "height": r.h}),
};
let _ = app.run("arrange.bounds", params);
}
/// Draw the frame of the active object, or the drag preview.
pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) {
if crate::crop::paint(app, painter, t, layout, pages, scale) {
return;
}
if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.end.is_some()) {
paint_end_drag(app, d, painter, t, layout, pages, scale);
return;
}
if let Some((o, a, b)) = connector_ends(app, layout) {
// A connector: a handle at each end (filled when glued).
let glued = match selected(app) {
Some((_, InlineObject::Shape { extra, .. })) => (extra.start.is_some(), extra.end.is_some()),
_ => (false, false),
};
for (p, on) in [(a, glued.0), (b, glued.1)] {
if let Some(c) = point_on_screen(pages, scale, o.page, p) {
painter.circle(c, 4.5, if on { t.accent } else { Color32::WHITE }, egui::Stroke::new(1.2, t.accent));
}
}
return;
}
if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.drag.moved) {
let Some(to) = screen(pages, scale, d.drag.to_page, d.drag.rect) else { return };
if d.drag.grab == Grab::Rotate {
if let Some(tex) = &d.preview
&& let Some(bounds) = screen(pages, scale, d.drag.page, d.object.bounds())
{
// The cached pixels already contain the starting rotation and flips. Turn
// only by the additional angle; changing UVs would mirror them a second time.
let mut mesh = egui::Mesh::with_texture(tex.id());
let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
mesh.add_rect_with_uv(bounds, uv, Color32::from_white_alpha(PREVIEW_OPACITY));
let delta = wordcraft_geom::normalize_degrees(d.drag.deg - d.object.spin.deg);
mesh.rotate(egui::emath::Rot2::from_angle(delta.to_radians()), to.center());
painter.add(egui::Shape::mesh(mesh));
}
frame::paint_turning(painter, t, to, d.drag.deg);
return;
}
if !shows_pixels(d) {
frame::paint_outline(painter, t, to, d.drag.deg);
} else if let Some(tex) = &d.preview {
// A turned object's pixels fill its rotated bounds, which move with it.
let r = d.object.bounds();
let (dx, dy) = (d.drag.rect.x - d.drag.start.x, d.drag.rect.y - d.drag.start.y);
let moved = wordcraft_geom::Rect::new(r.x + dx, r.y + dy, r.w, r.h);
let to = if d.object.spin.is_identity() { to } else { screen(pages, scale, d.drag.to_page, moved).unwrap_or(to) };
let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
painter.image(tex.id(), to, uv, Color32::from_white_alpha(PREVIEW_OPACITY));
}
frame::paint(painter, t, to, d.drag.deg, false, false, false);
paint_guides(d, painter, t, layout, pages, scale);
paint_drop_caret(d, painter, layout, pages, scale);
return;
}
// The others selected along with it (Shift+click): their outlines.
for p in &app.session.also_selected {
if let Some(o) = layout.object(p, app.session.page_hint)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
frame::paint(painter, t, f, o.spin.deg, false, false, false);
}
}
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
// Handles where it can be resized and turned (body and header/footer objects,
// not charts): else a plain frame.
let handles = selectable_story(app, o.story) && !fixed(app, &o);
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles);
}
}
/// The dashed insertion cursor where a dragged In Line with Text object will land.
fn paint_drop_caret(d: &ObjectDrag, painter: &Painter, layout: &DocLayout, pages: &[Rect], scale: f32) {
let Some((pos, color)) = &d.drop_at else { return };
let Some(c) = layout.caret_on(pos, d.drag.to_page) else { return };
let Some(r) = screen(pages, scale, c.page, wordcraft_geom::Rect::new(c.x, c.top, 0.0, c.height)) else { return };
painter.extend(egui::Shape::dashed_line(&[r.left_top(), r.left_bottom()], egui::Stroke::new(1.5, *color), 2.0, 2.0));
}
/// A connector's end being dragged: a line from its other end to the pointer, and the connection
/// sites of the shapes near the pointer (the one it would glue to, filled).
fn paint_end_drag(app: &WordApp, d: &ObjectDrag, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) {
let Some((which, at)) = d.end.filter(|_| d.drag.moved) else { return };
let Some((_, a, b)) = connector_ends(app, layout) else { return };
let fixed = if which { a } else { b };
let snap = wordcraft_engine::cmd::connectors::SNAP;
let mut best: Option<((f32, f32), f32)> = None;
if let Some(pg) = layout.pages.get(d.object.page) {
for it in &pg.items {
let wordcraft_layout::Placed::Object { rect, spin, story: StoryRef::Body, path, off, .. } = it else { continue };
let pos = Pos { story: StoryRef::Body, path: path.clone(), off: *off };
let Some(InlineObject::Shape { kind, .. }) = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
if kind.is_connector() || pos == d.object.pos() {
continue;
}
let r = [rect.x, rect.y, rect.w, rect.h];
// Sites of shapes the pointer is near.
if at.0 < rect.x - 4.0 * snap || at.0 > rect.right() + 4.0 * snap || at.1 < rect.y - 4.0 * snap || at.1 > rect.bottom() + 4.0 * snap {
continue;
}
for (i, _) in wordcraft_doc::connector::sites(*kind, rect.w, rect.h).iter().enumerate() {
let Some(p) = wordcraft_doc::connector::site_point(*kind, r, *spin, i as u32) else { continue };
let dist = ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt();
if let Some(c) = point_on_screen(pages, scale, d.object.page, p) {
painter.circle_stroke(c, 3.5, egui::Stroke::new(1.0, t.accent));
}
if dist <= snap && best.is_none_or(|b| dist < b.1) {
best = Some((p, dist));
}
}
}
}
let to = best.map_or(at, |b| b.0);
if let (Some(f), Some(e)) = (point_on_screen(pages, scale, d.object.page, fixed), point_on_screen(pages, scale, d.object.page, to)) {
painter.line_segment([f, e], egui::Stroke::new(1.5, t.accent));
painter.circle(e, 4.5, if best.is_some() { t.accent } else { Color32::WHITE }, egui::Stroke::new(1.2, t.accent));
}
}
/// The pointer over an object or a handle (or dragging): its cursor.
pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<egui::CursorIcon> {
if let Some(c) = crate::crop::cursor(app, layout, pages, scale, at) {
return Some(c);
}
if let Some(d) = &app.canvas.obj_drag {
return Some(if d.end.is_some() {
egui::CursorIcon::Crosshair
} else if d.copy && d.drag.moved {
egui::CursorIcon::Copy
} else {
d.drag.grab.cursor()
});
}
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}
grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() })
}
/// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box.
/// Returns true if handled.
pub fn key(app: &mut WordApp, key: egui::Key, m: egui::Modifiers) -> bool {
// Crop mode takes Esc and Enter (leaving it, the crop kept).
if crate::crop::key(app, key) {
return true;
}
if selected(app).is_none() {
return false;
}
let step = if m.ctrl || m.alt || m.command { NUDGE_FINE } else { NUDGE };
let (dx, dy) = match key {
egui::Key::ArrowLeft => (-step, 0.0),
egui::Key::ArrowRight => (step, 0.0),
egui::Key::ArrowUp => (0.0, -step),
egui::Key::ArrowDown => (0.0, step),
egui::Key::Enter => return enter_text_box(app),
_ => return false,
};
// Inline objects move with the text: arrows move the caret as usual.
app.run("arrange.nudge", json!({"dx": dx, "dy": dy})).is_ok()
}
/// With a text box selected, put the caret at the end of its text (typing goes there).
pub fn enter_text_box(app: &mut WordApp) -> bool {
let Some(id) = selected_text_box(app) else { return false };
// A box linked from another shows (and edits) the first box's story.
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
let end = app.session.doc.end_of(StoryRef::Part(id));
app.run("caret.set", json!({"pos": end})).is_ok()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::{Document, Pos};
use super::*;
/// 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));
}
/// 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);
}
}