Files
deckcraft/crates/engine/src/cmd/arrange.rs
T
Brandon ThomasandClaude Opus 5.5 ab0173c28a #133: Selection pane engine: restack inside groups, Show/Hide All, Lock like PowerPoint
- arrange.bringForward/sendBackward/bringToFront/sendToBack and arrange.reorder work on a
  group's children, which stay in their group (as PowerPoint's pane arrows and drag do);
  steps that change nothing leave no undo step.
- selection.select (pane click: Ctrl toggles, children and top-level objects don't mix,
  picking a hidden object shows it), selection.showAll (everything, children too) and
  selection.hideAll (top level).
- shape.lock cascades through groups; locked objects can be selected, formatted and typed
  in, but not moved, sized, rotated, flipped, reshaped or cropped (commands and canvas).
- PPTX: Lock reads and writes PowerPoint's full locking attribute set per element kind
  (observed by saving locked objects from PowerPoint); partial locks aren't Lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:03:46 -04:00

341 lines
14 KiB
Rust

//! Arrange: order, group, align, distribute, rotate and flip.
use deckcraft_geom::{Point, Rect, Xfrm};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)),
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
cmd!(
"arrange.reorder",
"Reorder Objects",
["Arrange"],
None,
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
has_slide,
reorder
),
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
cmd!(
"arrange.align",
"Align",
["Arrange", "Align or Distribute"],
None,
"{edge: left|center|right|top|middle|bottom, to?: slide|selection (default: slide for one object), ids?}",
has_selection,
align
),
cmd!(
"arrange.distribute",
"Distribute",
["Arrange", "Align or Distribute"],
None,
"{dir: horizontal|vertical, to?: slide|selection, ids?}",
has_selection,
distribute
),
cmd!("arrange.rotateLeft", "Rotate Left 90°", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s
.execute("shape.rotate", &with_param(p, "by", json!(-90.0)))),
cmd!("arrange.rotateRight", "Rotate Right 90°", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s
.execute("shape.rotate", &with_param(p, "by", json!(90.0)))),
cmd!("arrange.flipHorizontal", "Flip Horizontal", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s
.execute("shape.flip", &with_param(p, "axis", json!("horizontal")))),
cmd!("arrange.flipVertical", "Flip Vertical", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s
.execute("shape.flip", &with_param(p, "axis", json!("vertical")))),
cmd!("arrange.nudge", "Nudge", [], None, "{dx, dy, ids?} (arrow keys: 6 pt, with Alt 1 pt)", has_selection, |s, p| s
.execute("shape.move", p)),
]
}
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
let ids = targets(s, p)?;
// Already in place (front of its group, say): no change and no undo step.
let mut trial = s.doc()?.shapes().to_vec();
order_tree(&mut trial, &ids, how, 0);
if stacking(&trial) == stacking(s.doc()?.shapes()) {
return ok();
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
order_tree(list, &ids, how, 0);
Ok(())
})?;
ok()
}
/// Every shape id with its depth, in tree order.
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
let mut out = vec![];
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
out
}
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
if depth > 64 {
return;
}
if list.iter().any(|x| ids.contains(&x.id)) {
order_list(list, ids, how);
}
for sh in list.iter_mut() {
if let Some(children) = sh.children_mut() {
order_tree(children, ids, how, depth + 1);
}
}
}
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
match how {
2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
list.extend(rest);
list.append(&mut moved);
}
-2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
moved.extend(rest);
*list = moved;
}
1 => {
for i in (0..list.len().saturating_sub(1)).rev() {
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
if a == Some(true) && b == Some(false) {
list.swap(i, i + 1);
}
}
}
_ => {
for i in 1..list.len() {
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
if a == Some(false) && b == Some(true) {
list.swap(i - 1, i);
}
}
}
}
}
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
/// A shape stays in the list that holds it, so a group's child stays in its group.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
let st = s.doc()?;
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
None => return Err(bad("arrange.reorder", "no such shape")),
Some(None) => st.shapes(),
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
};
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
if from == to.min(siblings.len().saturating_sub(1)) {
return Ok(json!({"index": from}));
}
s.edit(|doc, sel| {
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let sh = list.remove(i);
let at = to.min(list.len());
list.insert(at, sh);
Ok(json!({"index": at}))
})
}
/// Bounds of shapes (rotation included).
fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
let mut it = xs.iter().map(Xfrm::bounds);
let first = it.next()?;
Some(it.fold(first, |a, b| a.union(b)))
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|doc, sel| {
let current = super::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut members: Vec<Shape> = current.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
if members.len() < 2 {
return Err(bad("arrange.group", "select two or more objects on the slide"));
}
for m in &mut members {
if m.xfrm.is_none() {
m.xfrm = Some(xfrm_of(doc, sel, m));
}
// Placeholders lose their link when grouped (PowerPoint keeps them out of groups).
m.ph = None;
}
let xs: Vec<Xfrm> = members.iter().filter_map(|m| m.xfrm).collect();
let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?;
let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height());
let id = ShapeId(doc.alloc_id());
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0);
let g = Shape {
id,
name: format!("Group {}", n + 1),
xfrm: Some(gx),
kind: ShapeKind::Group { children: members, child: gx },
..Default::default()
};
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.group", "no slide"))?;
let at = list.iter().rposition(|x| ids.contains(&x.id)).unwrap_or(list.len());
list.insert(at + 1, g);
list.retain(|x| !ids.contains(&x.id));
sel.shapes = vec![id];
Ok(json!({"id": id}))
})
}
/// A child's xfrm in slide space, given its group.
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
let center = a * Point::new(c.x + c.w / 2.0, c.y + c.h / 2.0);
let (w, h) = (c.w * sx.abs(), c.h * sy.abs());
let mut rot = c.rot + group.rot;
let (mut fh, mut fv) = (c.flip_h, c.flip_v);
if group.flip_h {
fh = !fh;
rot = -c.rot + group.rot;
}
if group.flip_v {
fv = !fv;
rot = -rot + 2.0 * group.rot;
}
Xfrm { x: center.x - w / 2.0, y: center.y - h / 2.0, w, h, rot: ((rot % 360.0) + 360.0) % 360.0, flip_h: fh, flip_v: fv }
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.ungroup", "no slide"))?;
let mut out = Vec::with_capacity(list.len());
let mut new_sel = vec![];
for sh in list.drain(..) {
if ids.contains(&sh.id)
&& let (ShapeKind::Group { children, child }, Some(gx)) = (&sh.kind, sh.xfrm)
{
for c in children {
let mut c = c.clone();
if let Some(cx) = c.xfrm {
c.xfrm = Some(child_to_parent(&gx, child, &cx));
}
// Nested groups scale too: their child space stays, their box is mapped.
new_sel.push(c.id);
out.push(c);
}
continue;
}
out.push(sh);
}
*list = out;
sel.shapes = new_sel.clone();
Ok(json!({"ids": new_sel}))
})
}
fn regroup(s: &mut Session, _p: &Value) -> Result<Value> {
// Regroup the most recently ungrouped objects (from the undo history).
let st = s.doc()?;
let last = st.history.undo.iter().rev().find(|e| e.label == "Ungroup").map(|e| e.selection.shapes.clone());
match last {
Some(prev_group) => {
// The ungroup's selection held the group id; its children are the current selection when nothing else changed.
let _ = prev_group;
let ids = s.doc()?.selection.shapes.clone();
if ids.len() >= 2 { s.execute("arrange.group", &json!({"ids": ids})) } else { Err(bad("arrange.regroup", "nothing to regroup")) }
}
None => Err(bad("arrange.regroup", "nothing to regroup")),
}
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let edge = str_param(p, "edge").unwrap_or("left").to_string();
let ids = targets(s, p)?;
let st = s.doc()?;
let size = st.doc.slide_size;
let boxes: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|id| st.shape(*id).map(|x| (*id, xfrm_of(&st.doc, &st.selection, x)))).collect();
let to_slide = match str_param(p, "to") {
Some("slide") => true,
Some(_) => false,
None => boxes.len() < 2,
};
let reference = if to_slide {
Rect::new(0.0, 0.0, size.width, size.height)
} else {
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
};
edit_geometry(s, p, "arrange.align", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let b = x.bounds();
let (dx, dy) = match edge.as_str() {
"left" => (reference.x0 - b.x0, 0.0),
"center" => (reference.center().x - b.center().x, 0.0),
"right" => (reference.x1 - b.x1, 0.0),
"top" => (0.0, reference.y0 - b.y0),
"middle" => (0.0, reference.center().y - b.center().y),
"bottom" => (0.0, reference.y1 - b.y1),
_ => (0.0, 0.0),
};
x.x += dx;
x.y += dy;
}
Ok(())
})
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let horiz = !str_param(p, "dir").unwrap_or("horizontal").starts_with('v');
let ids = targets(s, p)?;
let st = s.doc()?;
let size = st.doc.slide_size;
let mut boxes: Vec<(ShapeId, Rect)> =
ids.iter().filter_map(|id| st.shape(*id).map(|x| (*id, xfrm_of(&st.doc, &st.selection, x).bounds()))).collect();
let to_slide = str_param(p, "to") == Some("slide") || boxes.len() < 3;
if boxes.is_empty() {
return ok();
}
boxes.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = boxes.iter().map(|b| if horiz { b.1.width() } else { b.1.height() }).sum();
let (start, end) = if to_slide {
if horiz { (0.0, size.width) } else { (0.0, size.height) }
} else {
let f = boxes.first().map(|b| b.1).unwrap_or_default();
let l = boxes.last().map(|b| b.1).unwrap_or_default();
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
};
let n = boxes.len();
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
let mut targets_pos: Vec<(ShapeId, f64)> = vec![];
let mut cur = start;
if n == 1 {
let b = boxes.first().map(|b| b.1).unwrap_or_default();
cur = start + ((end - start) - if horiz { b.width() } else { b.height() }) / 2.0;
}
for (id, b) in &boxes {
let d = cur - if horiz { b.x0 } else { b.y0 };
targets_pos.push((*id, d));
cur += (if horiz { b.width() } else { b.height() }) + gap;
}
edit_geometry(s, p, "arrange.distribute", |sh| {
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
if horiz {
x.x += d;
} else {
x.y += d;
}
}
Ok(())
})
}