Files
wordcraft/crates/ui-egui/src/objects.rs
T

938 lines
46 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 bold insertion
//! cursor follows the pointer through the text (#2233, #4195) 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.
//!
//! Inside a Drawing Canvas, a press on a member picks that member (`select.member`, #2637): it
//! gets the frame and handles, inside the canvas's frame, and is dragged, resized and turned on
//! its own; a press in a text box member's text puts the caret there. The canvas itself is
//! picked where no member is.
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,
/// The original page's screen origin when pressed, before any drag-time scrolling.
press_origin: Pos2,
/// 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 bold 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: a chart or diagram WordCraft has no
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
/// cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
matches!(hit_object(app, hit), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
}
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
matches!(
hit_object(app, hit),
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
)
}
/// The object a hit is: the object at its position, or the canvas member it names.
fn hit_object<'a>(app: &'a WordApp, hit: &ObjectHit) -> Option<&'a InlineObject> {
let pos = hit.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off))?;
match (hit.member, o) {
(None, _) => Some(o),
(Some(i), InlineObject::Group { children, canvas: Some(_), .. }) => children.get(i).map(|c| &c.obj),
_ => None,
}
}
/// Member `index` of the canvas laid out at `canvas`, as a hit of its own: its frame where the
/// canvas draws it, its own turn. `None` when it isn't drawn, or the canvas is turned (its
/// members aren't picked on their own yet).
fn member_hit(app: &WordApp, canvas: &ObjectHit, index: usize) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
let (_, frame) = o.member_rects(r.x, r.y, r.w, r.h).into_iter().find(|(i, _)| *i == index)?;
member_with_frame(app, canvas, o, index, frame)
}
/// The canvas object of hit `canvas` when its members can be picked: a canvas, hit as a whole,
/// not turned.
fn pickable_canvas<'a>(app: &'a WordApp, canvas: &ObjectHit) -> Option<&'a InlineObject> {
let pos = canvas.pos();
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).filter(|o| o.is_canvas())?;
(canvas.member.is_none() && canvas.spin.is_identity()).then_some(o)
}
/// [`member_hit`] for member `index` of canvas `o`, whose frame on the page (`[x, y, w, h]`) is
/// already worked out.
fn member_with_frame(app: &WordApp, canvas: &ObjectHit, o: &InlineObject, index: usize, [x, y, w, h]: [f32; 4]) -> Option<ObjectHit> {
let InlineObject::Group { children, .. } = o else { return None };
let member = children.get(index).filter(|c| wordcraft_engine::cmd::canvas::pickable(c))?;
let text_box = match &member.obj {
InlineObject::Shape { story: Some(id), .. } if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
_ => None,
};
Some(ObjectHit {
rect: wordcraft_geom::Rect::new(x, y, w, h),
spin: member.obj.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(),
text_box,
// Placed by the canvas, not the text: dragging any handle may move its top-left.
wrap: Wrap::InFrontOfText,
member: Some(index),
..canvas.clone()
})
}
/// The member of the canvas hit `canvas` under the page point (x, y), topmost first; a text box
/// member is grabbed anywhere on it too (inside, [`in_text`] tells its text from its border).
/// The members' frames are worked out once for all of them.
fn member_at(app: &WordApp, canvas: &ObjectHit, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
// Members are drawn clipped to the canvas: only what shows is grabbed.
if !r.contains(wordcraft_geom::Point::new(x, y)) {
return None;
}
o.member_rects(r.x, r.y, r.w, r.h)
.into_iter()
.rev()
.filter_map(|(i, frame)| member_with_frame(app, canvas, o, i, frame))
.find(|m| m.rect.expand(edge).contains(m.unspin(x, y)))
}
/// Whether the page point (x, y) is in a text box member's text rather than on its border.
fn in_text(m: &ObjectHit, x: f32, y: f32, edge: f32) -> bool {
m.text_box.is_some() && m.rect.w.min(m.rect.h) > edge * 3.0 && m.rect.expand(-edge).contains(m.unspin(x, y))
}
/// The story of the selected text box (a picked canvas member too).
fn selected_text_box(app: &WordApp) -> Option<u32> {
if let Some((_, _, InlineObject::Shape { story: Some(id), .. })) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox).then_some(*id);
}
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) {
let canvas = layout.object(&pos, hint)?;
if let Some((_, i, _)) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
return member_hit(app, &canvas, i).or(Some(canvas)).map(|o| (o, false));
}
return Some((canvas, 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 && d.object.member.is_none();
if d.drag.grab == Grab::Rotate {
d.drag.turn_to(at, pages, scale, shift);
} else {
let locked_axis = if d.copy && shift && d.object.floating() {
let horizontal = d.drag.update_axis(at, pages, scale, d.min, d.press_origin);
Some(if horizontal { guides::Axis::Y } else { guides::Axis::X })
} 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);
None
};
// 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
&& d.object.member.is_none()
&& app.ui.alignment_guides
&& !ui.input(|i| i.modifiers.alt)
{
snap_move(layout, &mut d, scale, locked_axis);
}
}
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,
press_origin: pages.get(object.page).map_or(at, |p| p.min),
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);
if let Some(index) = object.member.filter(|_| pressed && !adding) {
return press_member(app, layout, pages, scale, at, object, index, grab);
}
// Adding to the selection takes the whole canvas.
let object = match object.member {
Some(_) => match layout.object(&object.pos(), object.page) {
Some(o) => o,
None => return false,
},
None => object,
};
// Ctrl on the object that is already selected starts a copy-drag instead, including
// Ctrl+Shift for an aligned copy. Shift alone keeps its additive-selection behavior.
let again = selected(app).is_some_and(|(p, _)| p == object.pos())
|| (ui.input(|i| i.modifiers.command && i.modifiers.shift)
&& wordcraft_engine::cmd::objects::picked_objects(&app.session).contains(&object.pos()));
let shift_only = ui.input(|i| i.modifiers.shift && !i.modifiers.command);
if pressed && adding && grab == Grab::Move && selected(app).is_some() && (shift_only || !again) {
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),
press_origin: pages.get(object.page).map_or(at, |p| p.min),
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.
let size_only = size_only(app, &object);
if !fixed(app, &object) && (grab != Grab::Move || (movable(app, &object) && !size_only)) {
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),
press_origin: pages.get(object.page).map_or(at, |p| p.min),
object,
min,
picture,
pending_behind: false,
end: None,
guides: Vec::new(),
preview: None,
copy,
drop_at: None,
});
}
}
true
}
/// A press on member `index` of a canvas (`object` is the member's hit): in a text box member's
/// text it puts the caret there; elsewhere it picks the member (selecting its canvas first) and
/// starts dragging it, or the handle `grab`.
#[allow(clippy::too_many_arguments)]
fn press_member(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, object: ObjectHit, index: usize, grab: Grab) -> bool {
let edge = EDGE / scale.max(0.01);
if grab == Grab::Move
&& let (Some(id), Some((page, x, y))) = (object.text_box, crate::canvas::page_at(pages, layout, scale, at))
&& in_text(&object, x, y, edge)
{
// A box linked from another shows (and edits) that box's story.
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
if let Some(pos) = layout.hit(page, x, y, StoryRef::Part(id)) {
let _ = app.run("caret.set", json!({"pos": pos}));
}
return true;
}
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}));
if app.run("select.member", json!({"index": index})).is_err() {
return true;
}
let Some(o) = hit_object(app, &object) else { return true };
let (min, picture) = (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. }));
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
press_origin: pages.get(object.page).map_or(at, |p| p.min),
object,
min,
picture,
pending_behind: false,
end: None,
guides: Vec::new(),
preview: None,
copy: false,
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. Nor for a member of a
/// drawing canvas: its position is the canvas's, and the canvas stays.
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 || d.object.member.is_some() {
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, locked_axis: Option<guides::Axis>) {
let Some(pg) = layout.pages.get(d.drag.to_page) else { return };
// Guides may snap along the free axis, but must not pull an aligned copy off its axis.
let mut candidates = guides::candidates(paper_width(pg), pg.h, pg.body, snap_targets(pg, &d.object));
candidates.retain(|g| Some(g.axis) != locked_axis);
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 !size_only(app, &o) && 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. Inside a canvas, the member there.
let edge = EDGE / scale.max(0.01);
let o = layout.object_at_in(page, x, y, edge, story)?;
Some((member_at(app, &o, x, y, edge).unwrap_or(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 {
// A canvas member moves inside its canvas, wherever the canvas is.
(o.member.is_some() || 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())
}
/// The pixels-per-page-point for a `w` x `h` preview, lowered so neither side exceeds the GPU's
/// `max_side` (the texture is painted into the object's rect, so a smaller one is only stretched).
fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
let room = (max_side.min(wordcraft_render::MAX_SIDE as usize).max(2) - 1) as f32;
let px = if px.is_finite() { px } else { 1.0 };
px.min(room / w.max(h).max(1.0))
}
/// `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 = hit_object(app, o)?;
let page = layout.pages.get(o.page)?;
let mut opts = crate::canvas::screen_render_options();
opts.dark = crate::canvas::dark_page(app);
let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display);
let b = o.bounds();
let max_side = ctx.input(|i| i.max_texture_side);
let px = preview_scale(scale * ctx.pixels_per_point(), b.w, b.h, max_side);
let img = wordcraft_render::render_items(&app.session.doc, &items, wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h), px, &opts);
// A texture over the device limit panics wgpu outside `Session::run`; fall back to the outline.
if img.width as usize > max_side || img.height as usize > max_side {
return None;
}
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;
// A chart or SmartArt takes a new size, never a new place.
if matches!(d.drag.grab, Grab::Resize(_)) && size_only(app, &d.object) {
let _ = app.run("picture.size", json!({"width": r.w, "height": r.h, "lockAspect": false}));
return;
}
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)
{
// A picked canvas member: the canvas's frame around it, without handles.
if o.member.is_some()
&& let Some(c) = layout.object(&o.pos(), o.page)
&& let Some(cf) = screen(pages, scale, c.page, c.rect)
{
frame::paint(painter, t, cf, c.spin.deg, false, false, false);
}
// 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 && !size_only(app, &o));
}
}
/// The insertion cursor where a dragged In Line with Text object will land: solid and twice as
/// wide as the caret, so it stands out as Word's does (#4195).
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.line_segment([r.left_top(), r.left_bottom()], egui::Stroke::new(3.0, *color));
}
/// 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(d.drag.deg)
});
}
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}
let in_member_text = |o: &ObjectHit| {
crate::canvas::page_at(pages, layout, scale, at).is_some_and(|(_, x, y)| o.member.is_some() && in_text(o, x, y, EDGE / scale.max(0.01)))
};
grab_at(app, layout, pages, scale, at)
.map(|(o, g)| if g == Grab::Move && !fixed(app, &o) && in_member_text(&o) { egui::CursorIcon::Text } else { hover_cursor(app, &o, g) })
}
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
/// fixed graphic, or the body of a chart or SmartArt, which only resizes).
fn hover_cursor(app: &WordApp, o: &ObjectHit, g: Grab) -> egui::CursorIcon {
if fixed(app, o) || (g == Grab::Move && size_only(app, o)) { egui::CursorIcon::Default } else { g.cursor(o.spin.deg) }
}
/// 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;