Merge content-move: move a placed graphic inside its frame

Direct Selection selects and drags a frame's content, the Selection tool
drags it by the content grabber, and double-clicking a frame with content
toggles between frame and content, alongside #84's text threading and
canvas click counting.

Conflicts:
- crates/tools/src/select.rs: module doc keeps both paragraphs (ports,
  content). The frame/content toggle runs on the second press of a double
  click (Down with clicks 2, or a DoubleClick from the control channel),
  Selection tool only, after threading ports and the text-frame
  double-click, so those keep priority; the DoubleClick arm stays empty.
  Overlays keep the Drag::Place marquee and add the content ghost.
- crates/tools/src/tests.rs: both sets of tests and fixtures kept; the
  graphic fixture sets Graphic::wrap; the double-click toggle test drives
  the click-count sequence and the DoubleClick form.
- crates/engine/src/tests.rs: both sets of tests kept.
- crates/engine/src/cmd/object.rs: both test modules kept.
This commit is contained in:
aiongg
2026-10-10 22:37:06 +00:00
7 changed files with 504 additions and 20 deletions
+133 -2
View File
@@ -26,7 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
frame_create
),
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
cmd!(
"transform.move",
"Move",
["Object", "Transform"],
None,
"{dx, dy, copy?: bool, ids?, toSpread?, content?: bool (move the placed graphic or pasted-in items inside each frame; the frame stays. Default: whether the selection is content, when neither ids nor copy is given)}",
has_selection,
transform_move
),
cmd!(
"transform.resize",
"Resize",
@@ -931,6 +939,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let copy = bool_or(p, "copy", false);
let explicit = p.get("ids").is_some() || p.get("id").is_some();
// Duplicating copies the frame; moving a content selection moves what the frame holds.
let content = p.get("content").and_then(Value::as_bool).unwrap_or(!explicit && !copy && s.doc()?.selection.content);
if content {
return move_content(s, &ids, Vec2::new(dx, dy));
}
let to: Option<SpreadRef> = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok());
s.edit(|d, sel| {
let ids = if copy {
@@ -955,16 +969,61 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
let it = d.remove_item_keep_story(*id)?;
d.insert_item(target, it, None)?;
}
// A nested item moves in its container's space.
let v = d.find(*id).map_or(Vec2::new(dx, dy), |l| linear_inverse(d.parent_xf(&l), Vec2::new(dx, dy)));
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
if it.locked {
continue;
}
it.xf = Affine::translate((dx, dy)) * it.xf;
it.xf = Affine::translate(v) * it.xf;
}
Ok(json!({"moved": ids.len()}))
})
}
/// `v` (a spread-space displacement) in the space `m` maps from. Degenerate maps leave it as is.
fn linear_inverse(m: Affine, v: Vec2) -> Vec2 {
let c = m.as_coeffs();
let lin = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
if lin.determinant().abs() < 1e-12 {
return v;
}
let r = lin.inverse() * Point::new(v.x, v.y);
if r.x.is_finite() && r.y.is_finite() { r.to_vec2() } else { v }
}
/// Move what each frame holds — its placed graphic or the items pasted into it — by the
/// spread-space displacement `d`; the frame stays, so the visible part of the content changes.
/// Frames without such content (and groups) move as a whole. Auto-fit stays as it is: the next
/// frame resize refits.
fn move_content(s: &mut Session, ids: &[ItemId], d: Vec2) -> Result<Value> {
s.edit(|doc, _| {
let mut moved = 0;
for id in ids {
let loc = doc.find(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
let parent = doc.parent_xf(&loc);
let it = doc.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
if it.locked {
continue;
}
let inner = linear_inverse(parent * it.xf, d);
let nested = it.has_nested_items();
match &mut it.content {
Content::Graphic(g) => g.xf = Affine::translate(inner) * g.xf,
Content::Group { items } if nested => {
for c in items.iter_mut() {
let c = Arc::make_mut(c);
c.xf = Affine::translate(inner) * c.xf;
}
}
_ => it.xf = Affine::translate(linear_inverse(parent, d)) * it.xf,
}
moved += 1;
}
Ok(json!({"moved": moved}))
})
}
fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?;
@@ -3026,3 +3085,75 @@ mod reorder_tests {
assert!(s.execute("object.setLayer", &json!({"ids": [a], "layer": 999})).is_err());
}
}
#[cfg(test)]
mod content_move_tests {
use super::*;
/// A session with a 40×20 image placed in a frame at (100, 100)–(300, 300).
fn placed() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": super::super::base64_encode(&png)})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
(s, ItemId(f))
}
/// Spread bounds of the frame and of its graphic.
fn boxes(s: &Session, id: ItemId) -> (Rect, Rect) {
let it = s.doc().unwrap().doc.item(id).unwrap().clone();
let g = it.graphic().unwrap();
(it.bounds(), (it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)))
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
}
#[test]
fn content_moves_inside_the_frame_in_one_undo_step() {
let (mut s, f) = placed();
let (frame0, g0) = boxes(&s, f);
s.execute("selection.set", &json!({"ids": [f.0], "content": true})).unwrap();
s.execute("transform.move", &json!({"dx": 10, "dy": -5})).unwrap();
let (frame1, g1) = boxes(&s, f);
assert!(near(frame1, frame0), "the frame stays");
assert!(near(g1, g0 + Vec2::new(10.0, -5.0)), "{g1:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(boxes(&s, f).1, g0));
// Explicit ids with `content` act on the content whatever the selection is.
s.execute("selection.set", &json!({"ids": [f.0]})).unwrap();
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 3})).unwrap();
assert!(near(boxes(&s, f).0, frame0) && near(boxes(&s, f).1, g0 + Vec2::new(3.0, 0.0)));
// A frame selection moves frame and content together.
s.execute("transform.move", &json!({"dx": 4})).unwrap();
assert!(near(boxes(&s, f).0, frame0 + Vec2::new(4.0, 0.0)) && near(boxes(&s, f).1, g0 + Vec2::new(7.0, 0.0)));
}
#[test]
fn content_moves_on_the_page_axes_in_a_rotated_frame_and_pasted_items_move_too() {
let (mut s, f) = placed();
s.execute("transform.rotate", &json!({"ids": [f.0], "angle": 90})).unwrap();
let (frame0, g0) = boxes(&s, f);
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 10})).unwrap();
let (frame1, g1) = boxes(&s, f);
assert!(near(frame1, frame0) && near(g1, g0 + Vec2::new(10.0, 0.0)), "{g0:?} → {g1:?}");
// Items pasted into a frame are its content.
let a = s.execute("frame.create", &json!({"rect": [400, 100, 420, 120], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [400, 300, 500, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let kids = |s: &Session| {
let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone();
(it.bounds(), it.children().iter().map(|c| it.xf.transform_rect_bbox(c.bounds())).collect::<Vec<_>>())
};
let (fb0, k0) = kids(&s);
s.execute("transform.move", &json!({"ids": [b], "content": true, "dx": 5, "dy": 6})).unwrap();
let (fb1, k1) = kids(&s);
assert!(near(fb1, fb0) && near(k1[0], k0[0] + Vec2::new(5.0, 6.0)), "{k0:?} → {k1:?}");
}
}
+35
View File
@@ -845,3 +845,38 @@ fn character_style_delete_rejects_builtin_styles() {
let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn direct_selection_drag_and_arrow_keys_move_placed_content() {
use designcraft_tools::{Mods, PointerEvent, PointerKind, SnapView, ToolKey};
let mut s = session();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png)})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
let xfs = |s: &Session| {
let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone();
(it.xf, it.graphic().unwrap().xf.translation())
};
let (frame0, g0) = xfs(&s);
let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };
let at = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(150.0, 150.0));
s.set_tool("directSelection");
s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 30.0, at.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, at.x + 30.0, at.y + 10.0), v).unwrap();
assert!(s.doc().unwrap().selection.content);
let (frame1, g1) = xfs(&s);
assert_eq!(frame1, frame0, "the frame stays");
assert_eq!(g1 - g0, designcraft_geom::Vec2::new(30.0, 10.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(xfs(&s).1, g0, "one undo step");
// Arrow keys nudge the selected content; Shift nudges ten times as far.
s.set_tool("selection");
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Down, Mods { shift: true, ..Default::default() }, v).unwrap();
let (frame2, g2) = xfs(&s);
assert_eq!(frame2, frame0);
assert_eq!(g2 - g0, designcraft_geom::Vec2::new(1.0, 10.0));
}
+9
View File
@@ -130,6 +130,15 @@ impl Session {
self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default()
}
/// Canvas centres of the content grabbers the Selection tool shows: the selected frame's and
/// the one under `hover`.
pub fn content_grabbers(&mut self, hover: Option<designcraft_geom::Point>, view: ViewInfo) -> Vec<designcraft_geom::Point> {
if self.tool.id() != "selection" {
return vec![];
}
self.with_ctx(view, |_, cx| designcraft_tools::select::grabbers(cx, hover).into_iter().map(|(_, c)| c).collect()).unwrap_or_default()
}
pub fn tool_busy(&self) -> bool {
self.tool.busy()
}
+3
View File
@@ -322,6 +322,9 @@ pub enum Overlay {
b: Point,
label: String,
},
/// The whole content of this frame drawn faintly, the part outside the frame included (while
/// the content moves inside it).
ContentGhost(ItemId),
}
/// Correction from a snap, in spread coordinates for `delta`.
+142 -13
View File
@@ -4,8 +4,12 @@
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
//!
//! Content (a placed graphic, or items pasted into a frame) moves inside its frame: the Direct
//! Selection tool selects and drags it, the Selection tool drags it by the content grabber (the
//! circle at the frame's centre), and double-clicking a frame toggles between frame and content.
use designcraft_doc::{ItemId, SpreadRef};
use designcraft_doc::{Content, Item, ItemId, SpreadRef};
use designcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
@@ -18,12 +22,15 @@ enum Drag {
Pending {
start: Point,
hit: bool,
/// The drag moves the selection's content inside its frames.
content: bool,
},
Move {
start: Point,
origin_spread: SpreadRef,
bounds0: Option<Rect>,
exclude: Vec<ItemId>,
content: bool,
},
Resize {
handle: usize,
@@ -162,6 +169,73 @@ impl SelectionTool {
}
}
/// A frame whose content moves on its own: a placed graphic or items pasted into it.
pub fn holds_content(it: &Item) -> bool {
!it.is_group() && (matches!(it.content, Content::Graphic(_)) || it.has_nested_items())
}
/// Grab radius of the content grabber, in screen pixels.
const GRABBER_PX: f64 = 9.0;
/// Canvas centre of the content grabber of `id` (the middle of its frame), when it has one. Frames
/// too small to drag around the grabber have none.
pub fn grabber_center(cx: &ToolContext, id: ItemId) -> Option<Point> {
let it = cx.doc.item(id)?;
if it.locked || !holds_content(it) {
return None;
}
let b = cx.item_canvas_xf(id)?.transform_rect_bbox(it.bounds());
let min = cx.tol(GRABBER_PX * 4.0);
(b.width() >= min && b.height() >= min && b.center().x.is_finite() && b.center().y.is_finite()).then(|| b.center())
}
/// Content grabbers on show: the single selected frame's and the hovered frame's.
pub fn grabbers(cx: &ToolContext, hover: Option<Point>) -> Vec<(ItemId, Point)> {
let mut ids = Vec::new();
if let [one] = cx.selection.items.as_slice()
&& cx.selection.text.is_none()
{
ids.push(*one);
}
if let Some((_, id)) = hover.and_then(|p| cx.hit(p)) {
let id = cx.doc.top_level_of(id).unwrap_or(id);
if !ids.contains(&id) {
ids.push(id);
}
}
ids.into_iter().filter_map(|id| grabber_center(cx, id).map(|c| (id, c))).collect()
}
/// The frame whose content grabber is under `p` (canvas).
pub fn grabber_at(cx: &ToolContext, p: Point) -> Option<ItemId> {
let r = cx.tol(GRABBER_PX);
grabbers(cx, Some(p)).into_iter().find(|(_, c)| (*c - p).hypot() <= r).map(|(id, _)| id)
}
/// Canvas union of the content of the selected frames (an item without content counts whole).
fn content_bounds(cx: &ToolContext) -> Option<Rect> {
let mut acc: Option<Rect> = None;
for id in &cx.selection.items {
let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue };
let b = match &it.content {
Content::Graphic(g) => (xf * it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)),
_ if it.has_nested_items() => {
let Some(r) = it.children().iter().map(|c| (xf * it.xf).transform_rect_bbox(c.bounds())).reduce(|a, b| a.union(b)) else { continue };
r
}
_ => {
let Some(local) = crate::snap::moving_bounds(it) else { continue };
xf.transform_rect_bbox(local)
}
};
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
continue;
}
acc = Some(acc.map_or(b, |have| have.union(b)));
}
acc
}
/// Handle positions (canvas) of a rect: 0 TL, 1 T, 2 TR, 3 R, 4 BR, 5 B, 6 BL, 7 L.
pub fn handles(r: Rect) -> [Point; 8] {
let c = r.center();
@@ -438,6 +512,18 @@ impl Tool for SelectionTool {
self.drag = Drag::None;
return actions;
}
// The second press of a double click on a frame with content: toggle between the frame and
// its content.
if !self.direct
&& matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.click_count() == 2
&& let Some(id) = cx.hit(p).map(|(_, id)| cx.doc.top_level_of(id).unwrap_or(id))
&& cx.doc.item(id).is_some_and(holds_content)
{
self.drag = Drag::None;
let content = !(cx.selection.content && cx.selection.contains(id));
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": content}))];
}
match ev.kind {
PointerKind::Move => {
self.hover_handle = handle_at(cx, p);
@@ -470,13 +556,24 @@ impl Tool for SelectionTool {
Drag::Resize { handle, start: p, from: cx.layout.xf(loc.spread).inverse().transform_rect_bbox(b), spread: loc.spread };
return vec![Action::Begin("Resize".into())];
}
// The content grabber: select the frame's content; a drag moves it.
if !self.direct
&& !ev.mods.shift
&& let Some(id) = grabber_at(cx, p)
{
self.drag = Drag::Pending { start: p, hit: true, content: true };
if cx.selection.items == [id] && cx.selection.content {
return vec![];
}
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": true}))];
}
// Cmd+Shift-click a parent item on a page: override it there and select the copy.
if ev.mods.cmd
&& ev.mods.shift
&& cx.hit(p).is_none()
&& let Some((page, id)) = cx.hit_parent_on_page(p)
{
self.drag = Drag::Pending { start: p, hit: true };
self.drag = Drag::Pending { start: p, hit: true, content: false };
return vec![Action::Exec("layout.overrideParentItems".into(), json!({"page": page, "ids": [id.0]}))];
}
match cx.hit(p) {
@@ -490,31 +587,46 @@ impl Tool for SelectionTool {
} else {
out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
}
} else if !cx.selection.contains(id) {
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
}
self.drag = Drag::Pending { start: p, hit: true };
self.drag = Drag::Pending { start: p, hit: true, content: self.direct };
let _ = sr;
out
}
None => {
self.drag = Drag::Pending { start: p, hit: false };
self.drag = Drag::Pending { start: p, hit: false, content: false };
if ev.mods.shift { vec![] } else { vec![Action::Exec("selection.set".into(), json!({"ids": []}))] }
}
}
}
PointerKind::Drag => match self.drag.clone() {
Drag::Pending { start, hit } => {
Drag::Pending { start, hit, content } => {
if (p - start).hypot() < cx.tol(3.0) {
return vec![];
}
self.shift_release = None;
if hit && !cx.selection.items.is_empty() {
// Direct Selection on items without content moves (or duplicates) them whole.
let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content));
let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
let exclude = cx.selection.items.clone();
self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_visible_bounds(), exclude };
let label = if ev.mods.alt { "Duplicate" } else { "Move" };
let mut v = vec![Action::Begin(label.into())];
let bounds0 = if content { content_bounds(cx) } else { cx.selection_visible_bounds() };
self.drag = Drag::Move { start, origin_spread: sr, bounds0, exclude: exclude.clone(), content };
let mut v = vec![];
// Dragging a content selection by its frame moves the frame: select it.
if !content && cx.selection.content {
let ids: Vec<u64> = exclude.iter().map(|i| i.0).collect();
v.push(Action::Exec("selection.set".into(), json!({"ids": ids})));
}
let label = if content {
"Move Content"
} else if ev.mods.alt {
"Duplicate"
} else {
"Move"
};
v.push(Action::Begin(label.into()));
v.extend(self.pointer(cx, ev));
v
} else {
@@ -522,7 +634,7 @@ impl Tool for SelectionTool {
vec![]
}
}
Drag::Move { start, origin_spread, bounds0, exclude } => {
Drag::Move { start, origin_spread, bounds0, exclude, content } => {
let mut d: Vec2 = p - start;
let mut screen_x_locked = false;
let mut screen_y_locked = false;
@@ -536,7 +648,8 @@ impl Tool for SelectionTool {
}
}
// Snap on the spread under the pointer. Shift uses that spread's view rotation.
let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
// Content stays in its frame, on the frame's spread.
let target = if content { origin_spread } else { cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread) };
// 180 degrees keeps the screen axis. 90 and 270 swap it into the other spread axis.
let odd_turn = cx.layout.slot(target).is_some_and(|slot| slot.rotation % 2 == 1);
let spread_x_locked = if odd_turn { screen_y_locked } else { screen_x_locked };
@@ -570,7 +683,7 @@ impl Tool for SelectionTool {
x_edges,
y_edges,
exclude: &exclude,
copying: ev.mods.alt,
copying: ev.mods.alt && !content,
lengths: [None, None],
angle: None,
radius: 0.0,
@@ -602,6 +715,10 @@ impl Tool for SelectionTool {
let dt = landed - q0;
(dt.x, dt.y)
};
if content {
let ids: Vec<u64> = exclude.iter().map(|i| i.0).collect();
return vec![Action::Preview("transform.move".into(), json!({"dx": dx, "dy": dy, "ids": ids, "content": true}))];
}
let mut params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt});
if target != origin_spread {
params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt, "toSpread": spread_json(target)});
@@ -803,14 +920,25 @@ impl Tool for SelectionTool {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match &self.drag {
Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { content: true, exclude, .. } => {
let mut v: Vec<Overlay> = exclude.iter().map(|id| Overlay::ContentGhost(*id)).collect();
v.extend(self.guides.iter().cloned());
v
}
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
_ => vec![],
}
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if let Drag::Move { content: true, .. } = self.drag {
return Cursor::HandGrab;
}
if self.direct {
return Cursor::ArrowHollow;
let over_content = matches!(self.drag, Drag::None)
&& anchor_at(cx, p).is_none()
&& cx.hit(p).is_some_and(|(_, id)| cx.doc.item(id).is_some_and(holds_content));
return if over_content { Cursor::Hand } else { Cursor::ArrowHollow };
}
if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) {
return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) };
@@ -824,6 +952,7 @@ impl Tool for SelectionTool {
match handle_at(cx, p) {
Some(h) => handle_cursor(h),
None if rotate_zone(cx, p).is_some() => Cursor::Rotate,
None if grabber_at(cx, p).is_some() => Cursor::Hand,
None => Cursor::Arrow,
}
}
+114
View File
@@ -939,3 +939,117 @@ fn clicks_in_a_row_edit_and_select_text() {
// Moving a little while pressed keeps the unit selected.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty());
}
/// A 100×100 frame at (100, 100) holding a 200×100 graphic that overhangs it.
fn graphic_frame(doc: &mut Document) -> ItemId {
let id = ItemId(doc.alloc());
let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: designcraft_doc::AssetId(1),
size: (200.0, 100.0),
xf: designcraft_geom::Affine::translate((50.0, 100.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
id
}
fn preview_params(a: &[Action]) -> serde_json::Value {
match a.last() {
Some(Action::Preview(cmd, p)) if cmd == "transform.move" => p.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn direct_selection_drags_content_inside_its_frame() {
let mut d = Document::new(&NewDocument::default());
let f = graphic_frame(&mut d);
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let none = Selection::default();
let mut t = create("directSelection");
let at = spread_pt(&l, 120.0, 130.0);
let a = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y));
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]);
let sel = Selection { content: true, ..Selection::items(vec![f]) };
let cx = ctx(&d, &sel, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0));
assert_eq!(a.first(), Some(&Action::Begin("Move Content".into())));
let p = preview_params(&a);
assert_eq!(
(p["dx"].as_f64(), p["dy"].as_f64(), &p["content"], &p["ids"]),
(Some(15.0), Some(-4.0), &serde_json::json!(true), &serde_json::json!([f.0]))
);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::HandGrab);
assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f)));
// Shift keeps the drag on one axis.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0).with_mods(Mods { shift: true, ..Default::default() }));
let p = preview_params(&a);
assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(15.0), Some(0.0)));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 15.0, at.y)), vec![Action::Commit]);
}
#[test]
fn selection_tool_grabber_moves_content_elsewhere_moves_the_frame() {
let mut d = Document::new(&NewDocument::default());
let f = graphic_frame(&mut d);
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let frame = Selection::items(vec![f]);
let content = Selection { content: true, ..Selection::items(vec![f]) };
let centre = spread_pt(&l, 150.0, 150.0);
let mut t = create("selection");
// Hovering the grabber shows the hand; it is there on hover before any click, too.
assert_eq!(t.cursor(&ctx(&d, &Selection::default(), &c, &l), centre, Mods::default()), Cursor::Hand);
let g = select::grabbers(&ctx(&d, &Selection::default(), &c, &l), Some(centre));
assert!(matches!(g.as_slice(), [(id, p)] if *id == f && near(*p, centre)), "{g:?}");
// Press on the grabber: the content is selected; the drag moves it.
let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, centre.x, centre.y));
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]);
let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, centre.x - 20.0, centre.y + 10.0));
let p = preview_params(&a);
assert_eq!((p["dx"].as_f64(), p["dy"].as_f64(), &p["content"]), (Some(-20.0), Some(10.0), &serde_json::json!(true)));
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, centre.x - 20.0, centre.y + 10.0)), vec![Action::Commit]);
// A press elsewhere in the frame keeps the content selection; dragging selects and moves the frame.
let edge = spread_pt(&l, 115.0, 185.0);
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)).is_empty());
let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x + 30.0, edge.y));
assert_eq!(a[0], Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0]})));
assert_eq!(a[1], Action::Begin("Move".into()));
let p = preview_params(&a);
assert_eq!((p["dx"].as_f64(), p.get("content"), p.get("ids")), (Some(30.0), None, None));
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, edge.x + 30.0, edge.y));
// With the frame selected, a drag away from the grabber moves the frame.
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y));
let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x, edge.y + 30.0));
assert_eq!(a[0], Action::Begin("Move".into()));
assert_eq!(preview_params(&a).get("content"), None);
}
#[test]
fn double_click_toggles_frame_and_content() {
let mut d = Document::new(&NewDocument::default());
let f = graphic_frame(&mut d);
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let at = spread_pt(&l, 120.0, 180.0);
let mut t = create("selection");
let frame = Selection::items(vec![f]);
let content = Selection { content: true, ..Selection::items(vec![f]) };
let set = |content: bool| vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": content}))];
// The canvas's presses in a row: the first selects the frame, the second its content.
let a = t.pointer(&ctx(&d, &Selection::default(), &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y));
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": false}))]);
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y));
assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(true));
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)).is_empty());
// With the content selected, the first press keeps it and the second selects the frame.
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty());
t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y));
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(false));
// A double click sent as such (control channel) toggles too.
let dbl = PointerEvent::new(PointerKind::DoubleClick, at.x, at.y);
assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &dbl), set(true));
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &dbl), set(false));
}
+68 -5
View File
@@ -322,6 +322,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout);
if !preview {
// Content grabbers: the selected frame's and the hovered one's (Selection tool).
let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p));
let vi = app.view_info();
for c in app.session.content_grabbers(hover, vi) {
let c = xf.to_screen(c);
painter.circle_stroke(c, 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
painter.circle_stroke(c, 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
}
}
draw_tool_overlays(app, &painter, &xf);
ime_output(app, ui.ctx(), &xf);
if rulers {
@@ -905,11 +915,6 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside);
}
// Content grabber on graphic frames.
if sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_))) {
painter.circle_stroke(u.center(), 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
painter.circle_stroke(u.center(), 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
}
}
// Threads.
if app.ui.text_threads || sel.items.iter().any(|i| doc.item(*i).is_some_and(|x| x.is_text_frame())) {
@@ -1219,6 +1224,8 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
}
let tok = Tokens::get(painter.ctx());
let ov = app.session.overlays(app.view_info());
let doc = app.session.active().map(|st| st.doc.clone());
let layout = app.session.layout();
for o in ov {
match o {
Overlay::Marquee(r) => {
@@ -1261,10 +1268,66 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text);
}
Overlay::Path { .. } => {}
Overlay::ContentGhost(id) => {
if let Some(doc) = &doc {
draw_content_ghost(painter, xf, doc, &layout, id);
}
}
}
}
}
/// A frame's whole content drawn faintly over the canvas, the part outside the frame included,
/// with its bounds outlined (while the content moves inside the frame).
fn draw_content_ghost(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) {
let Some(it) = doc.item(id) else { return };
let Some((a, _)) = item_canvas_xf(doc, layout, id) else { return };
let outline = Stroke::new(1.0, Color32::from_rgb(196, 111, 43));
let Content::Graphic(g) = &it.content else {
for c in it.children() {
let r = xf.rect((a * it.xf).transform_rect_bbox(c.bounds()));
painter.rect_stroke(r, 0.0, outline, StrokeKind::Middle);
}
return;
};
let m = a * it.xf * g.xf;
let (w, h) = g.size;
let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| xf.to_screen(m * p));
if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return;
}
if let Some(tex) = ghost_texture(painter.ctx(), doc, g) {
let mut mesh = egui::Mesh::with_texture(tex.id());
let tint = Color32::from_white_alpha(90);
for (p, uv) in corners.iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
mesh.vertices.push(egui::epaint::Vertex { pos: *p, uv, color: tint });
}
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
painter.add(egui::Shape::mesh(mesh));
}
painter.add(egui::Shape::closed_line(corners.to_vec(), outline));
}
/// A screen-resolution proxy of a placed graphic for the ghost, cached per asset.
fn ghost_texture(ctx: &egui::Context, doc: &Document, g: &designcraft_doc::Graphic) -> Option<egui::TextureHandle> {
let asset = doc.assets.get(&g.asset)?;
let key = egui::Id::new(("content_ghost", std::sync::Arc::as_ptr(&asset.data) as usize, asset.data.len(), asset.page));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(t);
}
// A 72 ppi proxy is enough for a faint preview.
let pm = designcraft_render::images::mip(&asset.data, asset.page, g.size.0, 1.0)?;
let (pw, ph) = (pm.width() as usize, pm.height() as usize);
let max = ctx.input(|i| i.max_texture_side).min(4096);
if pw == 0 || ph == 0 || pw > max || ph > max {
return None;
}
let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice());
let t = ctx.load_texture("content_ghost", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, t.clone()));
Some(t)
}
#[allow(clippy::too_many_arguments)]
fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let painter = ui.painter_at(full);