diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 9565cdf7b..7431261c9 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Connect Shapes", [], None, - "{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the closest pair), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}", + "{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the sites facing each other along the dominant direction between the shapes; a box has 0 top, 1 left, 2 bottom, 3 right; shape.sites lists them), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}", has_slide, connect ), diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index f3be4f4bb..f7ff83d85 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -1,4 +1,4 @@ -use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param}; +use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param, xfrm_of}; pub(super) fn sites(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?; @@ -12,27 +12,40 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result { return Err(bad("shape.connect", "missing `from` / `to`")); }; let st = s.doc()?; - let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default(); - let (a, b) = (site_list(from), site_list(to)); + // Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint + // glues to grouped shapes), its sites where the group draws them. + let shape = |id| { + crate::connect::locate(&st.doc, &st.selection, st.shapes(), id) + .ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0))) + }; + let ((sa, ta), (sb, tb)) = (shape(from)?, shape(to)?); + let (a, b) = (crate::connect::sites_in(&st.doc, &st.selection, sa, ta), crate::connect::sites_in(&st.doc, &st.selection, sb, tb)); if a.is_empty() || b.is_empty() { return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)")); } let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len()); - let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) { - (Some(i), Some(j)) => (i, j), - (fi, fj) => { - let mut best = (f64::MAX, 0, 0); - for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) { - for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) { - let d = (*pa - *pb).hypot(); - if d < best.0 { - best = (d, i, j); - } - } - } - (best.1, best.2) - } + // A site left unsaid is the one facing the other shape along the dominant direction between + // the two (a box mostly below is left from the bottom), which is where a person glues an + // arrow; "the closest pair" chose a side site for a diagonal neighbour and crossed boxes. + // The direction runs between the shapes' box centres: the mean of the sites is off-centre + // for a shape whose sites are not symmetric (a triangle's lie mostly along its base). + let centre = |sh, to_abs: deckcraft_geom::Affine| to_abs * xfrm_of(&st.doc, &st.selection, sh).center(); + let (ca, cb) = (centre(sa, ta), centre(sb, tb)); + let facing = |v: &[deckcraft_geom::Point], c: deckcraft_geom::Point, dx: f64, dy: f64| { + let len = dx.hypot(dy).max(1e-9); + v.iter() + .enumerate() + .map(|(i, q)| { + let (sx, sy) = (q.x - c.x, q.y - c.y); + let sl = sx.hypot(sy).max(1e-9); + (i, (sx * dx + sy * dy) / (sl * len)) + }) + .max_by(|p, q| p.1.total_cmp(&q.1)) + .map(|(i, _)| i) + .unwrap_or(0) }; + let ia = pick(&a, "fromSite").unwrap_or_else(|| facing(&a, ca, cb.x - ca.x, cb.y - ca.y)); + let ib = pick(&b, "toSite").unwrap_or_else(|| facing(&b, cb, ca.x - cb.x, ca.y - cb.y)); let (p0, p1) = (a[ia], b[ib]); let id = match id_param(p, "id", "shape.connect")? { Some(id) => id, diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index c7305d62e..845e72d60 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -338,7 +338,10 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result { _ => Some(sh.text.get_or_insert_with(TextBody::default)), }; let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) }; - let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + // The first run's formatting, or for text that is still empty the formatting its end + // mark holds (format.* before any text lands there), so a size set before text.set survives. + let keep = + body.paragraphs.first().map(|p| p.runs.first().map(|r| r.props.clone()).unwrap_or_else(|| p.end_props.clone())).unwrap_or_default(); let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); let mut nb = TextBody::from_text(&text); for (i, para) in nb.paragraphs.iter_mut().enumerate() { @@ -353,6 +356,9 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result { for r in &mut para.runs { r.props = keep.clone(); } + // The end mark keeps it too, so an empty `text` (no runs) carries the formatting to + // the text set next. + para.end_props = keep.clone(); } body.paragraphs = nb.paragraphs; Ok(()) diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index 158af0962..91e9bdcd4 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -47,6 +47,11 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } _ => { let body = sh.text.get_or_insert_with(TextBody::default); + // Text that is still empty keeps the formatting on an end mark, so a size set + // before the text lands survives it; a body with no paragraph has none. + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } ed::format_all(body, f); } } diff --git a/crates/engine/src/connect.rs b/crates/engine/src/connect.rs index 8cdbecda8..5991ba619 100644 --- a/crates/engine/src/connect.rs +++ b/crates/engine/src/connect.rs @@ -1,14 +1,21 @@ //! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached //! connectors whenever the shapes they connect move, resize or rotate. -use deckcraft_geom::{Point, Xfrm}; +use deckcraft_geom::{Affine, Point, Rect, Xfrm}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use crate::Selection; use crate::cmd::xfrm_of; -/// Connection sites of `s` in slide coordinates (index = site index stored on connectors). +/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on +/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`]. pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec { + sites_in(doc, sel, s, Affine::IDENTITY) +} + +/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the +/// slide, or the child space of the groups around it) onto the slide. +pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec { if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) { return vec![]; } @@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec { if local.is_empty() { local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)]; } - let a = x.affine(); + let a = to_abs * x.affine(); local.into_iter().map(|p| a * p).collect() } +/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the +/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint, +/// and its sites are where the group draws them. +pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> { + fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some((s, to_abs)); + } + if let ShapeKind::Group { children, child } = &s.kind { + let x = xfrm_of(doc, sel, s); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch); + if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) { + return Some(f); + } + } + } + None + } + rec(doc, sel, shapes, id, Affine::IDENTITY, 0) +} + +/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is +/// no such shape. +pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option> { + locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a)) +} + /// The nearest connection site within `radius` of `p` on any visible shape except `exclude`. pub fn nearest_site( doc: &Presentation, @@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) { let snapshot: Vec = list.clone(); let view = doc.clone(); let site = |id: ShapeId, idx: u32| -> Option { - let s = snapshot.iter().find(|s| s.id == id)?; - let v = sites(&view, sel, s); + // Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it. + let v = sites_of(&view, sel, &snapshot, id)?; v.get(idx as usize).or(v.first()).copied() }; let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 85cbe9d8c..ddf5bd243 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5956,3 +5956,90 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() { } let _ = std::fs::remove_dir_all(&dir); } + +/// G6: with no sites given, shape.connect joins the sites that face each other along the +/// dominant direction between the two shapes (a box mostly below leaves from the bottom), not +/// "the closest pair", which for a diagonal neighbour picked a side site and crossed boxes. +#[test] +fn connector_default_sites_follow_the_dominant_direction() { + let mut s = session(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let diag = s.execute("shape.insert", &json!({"preset": "rect", "rect": [150, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"from": a, "to": diag})).unwrap(); + assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(2), Some(0)), "mostly below: bottom → top, not right → left: {r}"); + let r = s.execute("shape.connect", &json!({"from": diag, "to": a})).unwrap(); + assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(0), Some(2)), "and back: top → bottom: {r}"); + // The direction runs between the shapes' centres, not between the means of their sites: a + // triangle's sites lie mostly along its base, which pulled the direction sideways and chose + // the box's left site for a box that is mostly below. + let tri = s.execute("shape.insert", &json!({"preset": "triangle", "rect": [400, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let below = s.execute("shape.insert", &json!({"preset": "rect", "rect": [520, 155, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"from": tri, "to": below})).unwrap(); + assert_eq!(r["toSite"].as_u64(), Some(0), "centre to centre is mostly down: the box's top, not its left: {r}"); +} + +/// A size (or colour, bold…) set on a shape before it has any text survives the text that is +/// then set: text.set keeps the first run's properties, and an empty paragraph keeps them on its +/// end mark, which is what the agent's format.size then set_text sequence relies on. +#[test] +fn text_set_keeps_the_formatting_of_an_empty_shape() { + let mut s = session(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 300, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("format.size", &json!({"size": 30})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("text.set", &json!({"id": id, "text": "Hello"})).unwrap(); + let props = + |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.clone(); + let run = props(&s); + assert_eq!((run.size, run.bold), (Some(30.0), Some(true)), "{run:?}"); + // Emptied and filled again: the empty paragraph's end mark carried the formatting. + s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); + s.execute("text.set", &json!({"id": id, "text": "Again"})).unwrap(); + let run = props(&s); + assert_eq!((run.size, run.bold), (Some(30.0), Some(true)), "after an empty text.set: {run:?}"); +} + +/// HH1: `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's +/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at +/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes, +/// and the sites of a grouped shape were computed in the group's child space. +#[test] +fn shape_connect_glues_to_a_shape_inside_a_group() { + use deckcraft_model::{ShapeId, ShapeKind}; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let glue = |s: &Session, id: u64| { + let st = s.doc().unwrap(); + match st.shape(ShapeId(id as u32)).unwrap().kind { + ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)), + _ => (None, None), + } + }; + let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6; + // A new connector into the group. + let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap(); + let k = r["id"].as_u64().unwrap(); + assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}"); + let (p0, p1) = line_ends(&s, k); + assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}"); + assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}"); + s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap(); + assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move"); + let (_, p1) = line_ends(&s, k); + assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}"); + // Re-gluing an existing line with `id`. + let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap(); + assert_eq!(r["id"], l, "{r}"); + assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}"); + let (_, p1) = line_ends(&s, l); + assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}"); + s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap(); + assert_eq!(glue(&s, l), (Some(c), Some(b))); + let (_, p1) = line_ends(&s, l); + assert!(near(p1, 160.0, 325.0), "{p1:?}"); +}