diff --git a/ROADMAP.md b/ROADMAP.md index 129157f..0f1e02f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | + +| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index c755050..0c91bba 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -13,7 +13,15 @@ pub fn specs() -> Vec { 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 in the stack (0 = back)}", has_slide, reorder), + 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), @@ -50,52 +58,98 @@ pub fn specs() -> Vec { fn order(s: &mut Session, p: &Value, how: i32) -> Result { 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"))?; - match how { - 2 => { - let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); - list.extend(rest); - list.append(&mut moved); - } - -2 => { - let (mut moved, rest): (Vec, Vec) = 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); - } - } - } - } + 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, 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, ids: &[ShapeId], how: i32) { + match how { + 2 => { + let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); + list.extend(rest); + list.append(&mut moved); + } + -2 => { + let (mut moved, rest): (Vec, Vec) = 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 { 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 list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?; - let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?; + 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); - list.insert(to.min(list.len()), sh); - Ok(()) - })?; - ok() + let at = to.min(list.len()); + list.insert(at, sh); + Ok(json!({"index": at})) + }) } /// Bounds of shapes (rotation included). @@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result { } else { union_bounds(&boxes.iter().map(|b| b.1).collect::>()).unwrap_or_default() }; - edit_shapes(s, p, "arrange.align", |sh| { + 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() { @@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result { targets_pos.push((*id, d)); cur += (if horiz { b.width() } else { b.height() }) + gap; } - edit_shapes(s, p, "arrange.distribute", |sh| { + 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; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 29c7013..ce909b0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod inspect; pub mod media; pub mod merge; pub mod review; +pub mod selection; pub mod shape; pub mod slide; pub mod table; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(insert::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); + v.extend(selection::specs()); v.extend(merge::specs()); v.extend(text::specs()); v.extend(format::specs()); @@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut }) } +/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop): +/// locked targets are left out, and when every target is locked nothing changes. +pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result { + let ids = targets(s, p)?; + let st = s.doc()?; + let free: Vec = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect(); + if !ids.is_empty() && free.is_empty() { + return Err(bad(cmd, "the object is locked")); + } + edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f) +} + pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec> { match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), diff --git a/crates/engine/src/cmd/selection.rs b/crates/engine/src/cmd/selection.rs new file mode 100644 index 0000000..2089041 --- /dev/null +++ b/crates/engine/src/cmd/selection.rs @@ -0,0 +1,126 @@ +//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are +//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`, +//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop). + +use deckcraft_model::{Shape, ShapeId}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "selection.select", + "Select", + ["Selection Pane"], + None, + "{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown", + has_slide, + select + ), + cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all), + cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all), + ] +} + +/// The Selection pane's click. Objects inside groups and top-level objects can't be selected +/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden +/// object from the list shows it again. +fn select(s: &mut Session, p: &Value) -> Result { + let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false)); + let st = s.doc()?; + let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some()); + let mut valid: Vec = ids.iter().copied().filter(|i| in_group(i).is_some()).collect(); + if valid.is_empty() { + return Err(bad("selection.select", "no such shape")); + } + let kind = valid.first().and_then(in_group); + valid.retain(|i| in_group(i) == kind); + let keep: Vec = if toggle || add { + let current = st.selection.shapes.clone(); + if current.first().is_some_and(|c| in_group(c) != kind) { + // Can't mix objects in groups with top-level ones: nothing changes. + return Ok(json!({"selected": current})); + } + current + } else { + vec![] + }; + let mut next = keep; + for i in &valid { + match next.iter().position(|x| x == i) { + Some(k) if toggle => { + next.remove(k); + } + Some(_) => {} + None => next.push(*i), + } + } + let reveal: Vec = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect(); + if reveal.is_empty() { + s.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = next.clone(); + })?; + } else { + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?; + for id in &reveal { + if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) { + sh.hidden = false; + } + } + sel.text = None; + sel.cells = None; + sel.shapes = next.clone(); + Ok(()) + })?; + } + Ok(json!({"selected": s.doc()?.selection.shapes})) +} + +fn show_all(s: &mut Session, _p: &Value) -> Result { + fn any_hidden(v: &[Shape]) -> bool { + let mut found = false; + deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden); + found + } + if !any_hidden(s.doc()?.shapes()) { + return ok(); + } + s.edit(|doc, sel| { + fn rec(v: &mut [Shape], depth: usize) { + for sh in v { + sh.hidden = false; + if depth < 64 + && let Some(c) = sh.children_mut() + { + rec(c, depth + 1); + } + } + } + rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0); + Ok(()) + })?; + ok() +} + +fn hide_all(s: &mut Session, _p: &Value) -> Result { + if s.doc()?.shapes().iter().all(|x| x.hidden) { + return ok(); + } + s.edit(|doc, sel| { + for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? { + sh.hidden = true; + } + Ok(()) + })?; + ok() +} + +#[cfg(test)] +#[path = "selection_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs new file mode 100644 index 0000000..11d99b7 --- /dev/null +++ b/crates/engine/src/cmd/selection_tests.rs @@ -0,0 +1,264 @@ +//! Selection pane: reorder, groups, hide/show, lock and undo. + +use serde_json::{Value, json}; + +use crate::tools::{Mods, PointerEvent, PointerKind}; +use crate::*; + +/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front. +fn deck() -> (Session, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = vec![]; + for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() { + let x = 20.0 + 110.0 * i as f64; + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap(); + ids.push(id); + } + let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap(); + let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap(); + ids.extend([h, g]); + (s, ids) +} + +/// The tree as text, back to front: `A B C G[D H[E F]]`. +fn tree(s: &Session) -> String { + fn rec(v: &[deckcraft_model::Shape]) -> String { + v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::>().join(" ") + } + rec(s.doc().unwrap().shapes()) +} + +/// (hidden, locked) +fn flags(s: &Session, id: u32) -> (bool, bool) { + let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap(); + (sh.hidden, sh.locked) +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect() +} + +fn undo(s: &mut Session) { + s.execute("edit.undo", &json!({})).unwrap(); +} + +fn bx(s: &mut Session, id: u32) -> Value { + s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() +} + +#[test] +fn bring_forward_and_send_backward_stay_inside_the_group() { + let (mut s, ids) = deck(); + let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]); + assert_eq!(tree(&s), "A B C G[D H[E F]]"); + s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[E F]]"); + // A child moves among its siblings only and stops at the group's front. + s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]"); + s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]"); + s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H"); + s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[F E] D]"); + s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[F E]]"); + s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G"); + // Steps that changed nothing left no undo step. + undo(&mut s); + assert_eq!(tree(&s), "B A C G[H[F E] D]"); + undo(&mut s); + undo(&mut s); + assert_eq!(tree(&s), "B A C G[D H[E F]]"); +} + +#[test] +fn reorder_is_the_drag_and_drop_among_siblings() { + let (mut s, ids) = deck(); + let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]); + // Drag A to the front of the slide (an index past the end clamps). + assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3); + assert_eq!(tree(&s), "B C G[D H[E F]] A"); + s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap(); + assert_eq!(tree(&s), "G[D H[E F]] B C A"); + // A child is dragged within its group: it never leaves it. + s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap(); + assert_eq!(tree(&s), "G[H[E F] D] B C A"); + s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap(); + assert_eq!(tree(&s), "G[H[F E] D] B C A"); + // Dropping where it already is changes nothing (no undo step). + let n = s.doc().unwrap().history.undo.len(); + s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + // One undo step per drop. + undo(&mut s); + assert_eq!(tree(&s), "G[H[E F] D] B C A"); + undo(&mut s); + undo(&mut s); + undo(&mut s); + assert_eq!(tree(&s), "A B C G[D H[E F]]"); + assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err()); + assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err()); +} + +#[test] +fn eye_show_all_and_hide_all() { + let (mut s, ids) = deck(); + let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]); + s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap(); + assert!(flags(&s, b).0); + // Hiding a group hides only the group; its children keep their own flags. + s.execute("shape.visible", &json!({"id": g})).unwrap(); + assert!(flags(&s, g).0 && !flags(&s, d).0); + s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap(); + // Hide All hides the top level only. + s.execute("selection.hideAll", &json!({})).unwrap(); + assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0); + // Show All shows everything, children included. + s.execute("selection.showAll", &json!({})).unwrap(); + for id in &ids { + assert!(!flags(&s, *id).0, "{id} still hidden"); + } + let n = s.doc().unwrap().history.undo.len(); + s.execute("selection.showAll", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step"); + undo(&mut s); + assert!(flags(&s, a).0 && flags(&s, e).0); + undo(&mut s); + assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0); +} + +#[test] +fn lock_cascades_through_groups_and_undoes() { + let (mut s, ids) = deck(); + let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]); + s.execute("shape.lock", &json!({"id": a})).unwrap(); + assert!(flags(&s, a).1); + s.execute("shape.lock", &json!({"id": a})).unwrap(); + assert!(!flags(&s, a).1, "toggles"); + // Locking a group locks everything in it. + s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap(); + assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1); + // A child can be unlocked on its own. + s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap(); + assert!(flags(&s, g).1 && !flags(&s, d).1); + // Unlocking the group unlocks everything in it. + s.execute("shape.lock", &json!({"id": g})).unwrap(); + assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1); + undo(&mut s); + assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1); + undo(&mut s); + undo(&mut s); + assert!(!flags(&s, g).1 && !flags(&s, e).1); +} + +#[test] +fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() { + let (mut s, ids) = deck(); + let (a, b) = (ids[0], ids[1]); + s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); + let before = bx(&mut s, a); + for (cmd, p) in [ + ("shape.move", json!({"ids": [a], "dx": 10})), + ("shape.resize", json!({"ids": [a], "w": 300})), + ("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})), + ("shape.rotate", json!({"ids": [a], "deg": 45})), + ("shape.flip", json!({"ids": [a], "axis": "vertical"})), + ("arrange.align", json!({"ids": [a], "edge": "right"})), + ("shape.change", json!({"ids": [a], "preset": "ellipse"})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape"); + } + assert_eq!(bx(&mut s, a), before); + // Mixed selections: the unlocked ones move, the locked one stays. + let bb = bx(&mut s, b); + s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap(); + assert_eq!(bx(&mut s, a), before); + assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0); + // Formatting and the name still change. + s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); + s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap(); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor"); + // The arrow keys don't nudge it. + s.execute("edit.select", &json!({"ids": [a]})).unwrap(); + let _ = s.key("ArrowRight", Mods::default()); + assert_eq!(bx(&mut s, a), before); +} + +#[test] +fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { + let (mut s, ids) = deck(); + let a = ids[0]; + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + // A: x 20..120, y 50..130. + s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); + assert_eq!(selected(&s), vec![a], "a click selects a locked object"); + s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap(); + assert_eq!(bx(&mut s, a)["x"], 20.0); + // No resize handle: pressing on the SE corner and dragging doesn't size it. + s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap(); + assert_eq!(bx(&mut s, a)["w"], 100.0); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing"); +} + +#[test] +fn select_from_the_pane() { + let (mut s, ids) = deck(); + let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]); + s.execute("selection.select", &json!({"id": b})).unwrap(); + assert_eq!(selected(&s), vec![b]); + s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![b, c]); + s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![c]); + s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap(); + assert_eq!(selected(&s), vec![c, a, b]); + // A child inside a group; children across nesting levels go together. + s.execute("selection.select", &json!({"id": d})).unwrap(); + assert_eq!(selected(&s), vec![d]); + s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![d, e]); + // ...but not with top-level objects: Ctrl+click on one is ignored. + s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![d, e]); + s.execute("selection.select", &json!({"id": a})).unwrap(); + s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![a]); + // Picking makes no undo step... + let n = s.doc().unwrap().history.undo.len(); + s.execute("selection.select", &json!({"id": b})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + // ...unless it shows a hidden object again. + s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap(); + s.execute("selection.select", &json!({"id": c})).unwrap(); + assert_eq!(selected(&s), vec![c]); + assert!(!flags(&s, c).0, "picking a hidden object shows it"); + undo(&mut s); + assert!(flags(&s, c).0); + assert!(s.execute("selection.select", &json!({"id": 4242})).is_err()); +} + +#[test] +fn rename_from_the_pane_undoes() { + let (mut s, ids) = deck(); + let e = ids[4]; + s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap(); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle"); + undo(&mut s); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E"); +} diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 7e12f60..22cb385 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -76,7 +76,15 @@ pub fn specs() -> Vec { cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename), cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text), cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible), - cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock), + cmd!( + "shape.lock", + "Lock", + ["Selection Pane"], + None, + "{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)", + has_selection, + lock + ), cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), cmd!( @@ -113,7 +121,7 @@ pub fn specs() -> Vec { fn move_by(s: &mut Session, p: &Value) -> Result { let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy")); let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y")); - edit_shapes(s, p, "shape.move", |sh| { + edit_geometry(s, p, "shape.move", |sh| { if let Some(x) = sh.xfrm.as_mut() { x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0); x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0); @@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result { fn resize(s: &mut Session, p: &Value) -> Result { let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0))); let lock = bool_or(p, "lockAspect", false); - edit_shapes(s, p, "shape.resize", |sh| { + edit_geometry(s, p, "shape.resize", |sh| { if let Some(x) = sh.xfrm.as_mut() { let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); @@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result { } fn set_bounds(s: &mut Session, p: &Value) -> Result { - edit_shapes(s, p, "shape.setBounds", |sh| { + edit_geometry(s, p, "shape.setBounds", |sh| { if let Some(x) = sh.xfrm.as_mut() { x.x = f64_param(p, "x").unwrap_or(x.x); x.y = f64_param(p, "y").unwrap_or(x.y); @@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { fn rotate(s: &mut Session, p: &Value) -> Result { let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); - edit_shapes(s, p, "shape.rotate", |sh| { + edit_geometry(s, p, "shape.rotate", |sh| { if let Some(x) = sh.xfrm.as_mut() { let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0); x.rot = ((r % 360.0) + 360.0) % 360.0; @@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { fn flip(s: &mut Session, p: &Value) -> Result { let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v'); - edit_shapes(s, p, "shape.flip", |sh| { + edit_geometry(s, p, "shape.flip", |sh| { if let Some(x) = sh.xfrm.as_mut() { if v { x.flip_v = !x.flip_v; @@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result { fn adjust(s: &mut Session, p: &Value) -> Result { let i = usize_param(p, "index").unwrap_or(0).min(16); let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?; - edit_shapes(s, p, "shape.adjust", |sh| { + edit_geometry(s, p, "shape.adjust", |sh| { if let Geom::Preset { name, adj } = &mut sh.geom { let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); while adj.len() <= i { @@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result { fn change(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; - edit_shapes(s, p, "shape.change", |sh| { + edit_geometry(s, p, "shape.change", |sh| { sh.geom = Geom::preset(info.name); Ok(()) }) @@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result { }) } +/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated, +/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it +/// (a child can still be unlocked on its own afterwards), as PowerPoint does. fn lock(s: &mut Session, p: &Value) -> Result { let v = bool_param(p, "locked"); edit_shapes(s, p, "shape.lock", |sh| { - sh.locked = v.unwrap_or(!sh.locked); + let to = v.unwrap_or(!sh.locked); + set_locked(sh, to, 0); Ok(()) }) } +fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) { + sh.locked = to; + if depth > 64 { + return; + } + if let Some(children) = sh.children_mut() { + for c in children { + set_locked(c, to, depth + 1); + } + } +} + fn set_default(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() }; @@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result { fn crop(s: &mut Session, p: &Value) -> Result { let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")]; - edit_shapes(s, p, "picture.crop", |sh| { + edit_geometry(s, p, "picture.crop", |sh| { if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { for (i, v) in c.iter().enumerate() { if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 876ea6e..bf8b5c3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -219,6 +219,10 @@ impl Session { if st.selection.text.is_none() || st.selection.shapes.len() == 1 { for id in st.selection.shapes.iter().rev() { let Some(sh) = st.shape(*id) else { continue }; + // A locked object shows no live handles: it can't be sized, rotated or reshaped. + if sh.locked { + continue; + } let x = xfrm_of(&st.doc, &st.selection, sh); if sh.is_line() { let a = x.affine(); @@ -247,8 +251,9 @@ impl Session { } } // Shapes, topmost first. + // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { - if sh.hidden || sh.locked { + if sh.hidden { continue; } if let Some(h) = hit_shape(st, sh, p, tol) { @@ -423,7 +428,9 @@ impl Session { } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + // Locked objects stay put when the selection is dragged. + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and // placeholders take the caret straight away like PowerPoint. let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); @@ -433,7 +440,8 @@ impl Session { sel.text = None; sel.cells = None; })?; - self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text }); + let duplicate = mods.alt && origs.len() == ids.len(); + self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); // Format Painter applies on click. if self.painter.is_some() && !toggle { let _ = self.execute("format.painterApply", &json!({"ids": [id]})); @@ -464,7 +472,7 @@ impl Session { match drag { Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; - if !moved && d.hypot() < tol.max(1.0) { + if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { return Ok(Value::Null); } if !moved { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 75d4716..c3f0f78 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -443,6 +443,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> { rec(shapes, id, 0) } +/// The list holding `id` among its siblings: the tree's top level, or a group's children. +pub fn sibling_list_mut(shapes: &mut Vec, id: ShapeId) -> Option<&mut Vec> { + fn rec(shapes: &mut Vec, id: ShapeId, depth: usize) -> Option<&mut Vec> { + if depth > 64 { + return None; + } + if shapes.iter().any(|s| s.id == id) { + return Some(shapes); + } + for s in shapes.iter_mut() { + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent. +pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option> { + fn rec(shapes: &[Shape], parent: Option, id: ShapeId, depth: usize) -> Option> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(parent); + } + if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, None, id, 0) +} + /// Background of a slide, layout or master. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 4423ad1..61a8922 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -210,3 +210,23 @@ fn a_layout_colour_map_override_applies_to_its_slides() { assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); } + +#[test] +fn sibling_lists_and_parents_reach_into_groups() { + let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() }; + let group = |id: u32, children: Vec| Shape { + id: ShapeId(id), + kind: ShapeKind::Group { children, child: Default::default() }, + ..Default::default() + }; + let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)]; + assert_eq!(parent_of(&tree, ShapeId(1)), Some(None)); + assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2)))); + assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4)))); + assert_eq!(parent_of(&tree, ShapeId(9)), None); + let ids = |v: &Vec| v.iter().map(|s| s.id.0).collect::>(); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); + assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); +} diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 7cb6635..db778de 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -104,6 +104,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { } } +/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking +/// attributes; it reads an object as locked only when the whole set for its kind is there, so a +/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock. +fn is_locked(l: &El) -> bool { + let set: &[&str] = match l.local() { + "grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"], + "graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"], + _ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"], + }; + set.iter().all(|a| l.bool(a) == Some(true)) +} + /// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…). fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { let c = nv.and_then(|n| n.child("cNvPr")); @@ -122,10 +134,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { match e.local() { "cNvSpPr" => { s.text_box = e.bool("txBox").unwrap_or(false); - s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true)); + s.locked = e.child("spLocks").is_some_and(is_locked); } "cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => { - s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true)); + s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked); } "nvPr" => { if let Some(ph) = e.child("ph") { diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index f8e415d..8b2ea35 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) { } } +/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and +/// reads back as locked): see `read::shapes::is_locked`. fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) { - let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked); + let a = extra.t("noGrp", s.ph.is_some() || s.locked); + let a = match tag { + "a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked), + _ => { + let a = a + .t("noRot", s.locked) + .t("noMove", s.locked) + .t("noResize", s.locked) + .t("noEditPoints", s.locked) + .t("noAdjustHandles", s.locked) + .t("noChangeArrowheads", s.locked) + .t("noChangeShapeType", s.locked); + if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a } + } + }; if a.v.is_empty() { return; } @@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.open0("p:nvGraphicFramePr"); cnvpr(w, x, o, id, s); w.open0("p:cNvGraphicFramePr"); - w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked)); + w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked)); w.close("p:cNvGraphicFramePr"); nv_pr(w, s); w.close("p:nvGraphicFramePr"); diff --git a/crates/pptx/tests/locks.rs b/crates/pptx/tests/locks.rs new file mode 100644 index 0000000..2e903e6 --- /dev/null +++ b/crates/pptx/tests/locks.rs @@ -0,0 +1,211 @@ +//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults}; + +fn png() -> Vec { + let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() } +} + +/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a +/// locked group (one child unlocked, one hidden), and a locked picture, connector and table. +fn deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let img = p.add_media("image1.png", "image/png", png()); + let mut v = vec![]; + let mut a = shape(&mut p, "Locked", 0.0); + a.locked = true; + v.push(a); + v.push(shape(&mut p, "Free", 100.0)); + let mut t = shape(&mut p, "Hidden text", 200.0); + t.text_box = true; + t.hidden = true; + t.locked = true; + v.push(t); + let mut c1 = shape(&mut p, "Child locked", 300.0); + c1.locked = true; + let c2 = shape(&mut p, "Child free", 400.0); + let mut c3 = shape(&mut p, "Child hidden", 500.0); + c3.hidden = true; + let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0); + let mut g = Shape { + id: ShapeId(p.alloc_id()), + name: "Group".into(), + xfrm: Some(gx), + kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx }, + locked: true, + ..Default::default() + }; + g.hidden = false; + v.push(g); + let mut pic = shape(&mut p, "Pic", 0.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } }; + pic.locked = true; + v.push(pic); + let mut cx = shape(&mut p, "Conn", 100.0); + cx.kind = ShapeKind::Connector { start: None, end: None }; + cx.geom = deckcraft_model::Geom::preset("straightConnector1"); + cx.locked = true; + v.push(cx); + let mut tb = shape(&mut p, "Tbl", 200.0); + tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0)); + tb.locked = true; + v.push(tb); + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = v; + } + p +} + +fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape { + let mut found = None; + deckcraft_model::walk(&s.shapes, &mut |x, _| { + if found.is_none() && x.name == name { + found = Some(x); + } + }); + found.unwrap_or_else(|| panic!("no shape {name}")) +} + +fn entries(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + (f.name().to_string(), d) + }) + .collect() +} + +fn rezip(es: &[(String, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, d) in es { + z.start_file(n.as_str(), o).expect("start"); + z.write_all(d).expect("write"); + } + z.finish().expect("finish").into_inner() +} + +fn slide1_xml(bytes: &[u8]) -> String { + let es = entries(bytes); + let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + String::from_utf8(d.clone()).expect("utf8") +} + +/// The element following `name="…"` up to the end of the object's non-visual properties. +fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str { + let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}")); + let rest = &xml[i..]; + let j = rest.find(" = s.shapes.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]); + let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect(); + assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]); +} + +#[test] +fn lock_writes_powerpoints_full_attribute_set() { + let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export")); + let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#; + assert!(nv_of(&xml, "Locked").contains(&format!("")), "{}", nv_of(&xml, "Locked")); + assert!(nv_of(&xml, "Hidden text").contains(&format!(""))); + assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#)); + assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free")); + assert!(nv_of(&xml, "Group").contains(r#""#)); + assert!(nv_of(&xml, "Conn").contains(&format!("")), "{}", nv_of(&xml, "Conn")); + assert!(nv_of(&xml, "Pic").contains(&format!(r#""#)), "{}", nv_of(&xml, "Pic")); + assert!(nv_of(&xml, "Tbl").contains(r#""#)); +} + +/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's +/// partial lock), or the set without `noGrp`, isn't locked. +#[test] +fn partial_locks_are_not_lock() { + let mut p = defaults::new_presentation(None); + let a = shape(&mut p, "A", 0.0); + let b = shape(&mut p, "B", 100.0); + let c = shape(&mut p, "C", 200.0); + let mut d = shape(&mut p, "D", 300.0); + d.locked = true; + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d]; + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + let xml = String::from_utf8(slide.1.clone()).expect("utf8"); + let swap = |xml: String, name: &str, locks: &str| { + let i = xml.find(&format!("name=\"{name}\"")).expect("name"); + let j = i + xml[i..].find("{locks}{}", &xml[..j], &xml[k..]) + }; + let xml = swap(xml, "A", r#""#); + let xml = swap( + xml, + "B", + r#""#, + ); + let xml = + swap(xml, "C", r#""#); + slide.1 = xml.into_bytes(); + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + let s = &q.slides[0]; + assert!(!find(s, "A").locked); + assert!(!find(s, "B").locked); + assert!(!find(s, "C").locked); + assert!(find(s, "D").locked); +} + +/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked. +#[test] +fn empty_locks_element_is_unlocked() { + let mut p = defaults::new_presentation(None); + let a = shape(&mut p, "A", 0.0); + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = vec![a]; + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("", "", 1); + slide.1 = xml.into_bytes(); + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + assert!(!find(&q.slides[0], "A").locked); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 644e2f6..bf138c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -461,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } else { poly(painter, corners, sel_stroke); } + if sh.locked && !editing_this { + // Locked: greyed handles that don't respond, no rotate or adjust handles. + for h in box_handles(&x).0 { + painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); + } + continue; + } if st.selection.shapes.len() == 1 || !editing_this { let (hs, rot) = box_handles(&x); let rot_s = xf.to_screen(rot); @@ -640,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ff9897c..aa3f2b5 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -284,6 +284,8 @@ icons! { Info => "info", Warning => "warning", Lock => "lock", + /// Open padlock (Selection pane: an object that isn't locked). + Unlock => "unlock", Eye => "eye", EyeOff => "eye-off", Close => "close", @@ -2262,22 +2264,30 @@ fn draw(p: &Pen<'_>, icon: Icon) { p.seg((12.0, 9.0), (12.0, 14.0), D); p.dot((12.0, 17.2), 1.2, D); } + // Selection pane toggles: monochrome outlines in the ink colour. I::Lock => { let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); sh.push((16.5, 10.5)); p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K); - p.dot((12.0, 15.0), 1.5, D); - p.seg((12.0, 15.5), (12.0, 18.3), D); + p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.seg((12.0, 14.5), (12.0, 17.5), K); + } + I::Unlock => { + // The shackle swung open: its left leg out of the body, raised. + let mut sh = vec![(12.0, 10.5), (12.0, 6.0)]; + sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((21.0, 8.5)); + p.line(&sh, K); + p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.seg((9.75, 14.5), (9.75, 17.5), K); } I::Eye | I::EyeOff => { - let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16); - e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1)); + let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16); + e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1)); e.pop(); p.shape(&e, N, K); - p.circle((12.0, 12.0), 3.4, B, N); - p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W }); + p.circle((12.0, 12.0), 3.2, N, K); if icon == I::EyeOff { p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d786b2..25a8323 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod media; pub mod menus; pub mod panes; pub mod ribbon; +pub mod selpane; pub mod show; pub mod sorter; pub mod status; @@ -219,6 +220,8 @@ pub struct SlideApp { pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// Selection pane: folded groups, renaming, drag and drop. + pub selpane: selpane::State, } impl SlideApp { @@ -258,6 +261,7 @@ impl SlideApp { notes_buf: (0, usize::MAX, String::new()), ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, + selpane: selpane::State::default(), } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fff1da4..2b3ada3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "format" => format_shape(app, ui), "background" => background(app, ui), "animation" => animation(app, ui), - "selection" => selection(app, ui), + "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), _ => {} @@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = Stroke::NONE; } -fn selection(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let shapes: Vec<(u32, String, bool, &'static str)> = - st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect(); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - ui.horizontal(|ui| { - if ui.button("Show All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": true})); - } - if ui.button("Hide All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": false})); - } - }); - ui.separator(); - let n = shapes.len(); - for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); - let sel = selected.contains(&id); - if sel || resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); - } - ui.painter().text( - pos2(r.min.x + 8.0, r.center().y), - Align2::LEFT_CENTER, - &name, - theme::font(12.5), - if hidden { t.text_faint } else { t.text }, - ); - ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint); - let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false); - let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); - if eye_resp.clicked() { - run(app, "shape.visible", json!({"id": id, "visible": hidden})); - } else if resp.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - run(app, "edit.select", json!({"ids": [id], "toggle": add})); - } - if resp.double_clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - } - resp.context_menu(|ui| { - if ui.button("Bring Forward").clicked() { - run(app, "arrange.bringForward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Send Backward").clicked() { - run(app, "arrange.sendBackward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Rename…").clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - ui.close(); - } - }); - let _ = (k, n); - } -} - fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs new file mode 100644 index 0000000..414a61f --- /dev/null +++ b/crates/ui-egui/src/selpane.rs @@ -0,0 +1,412 @@ +//! Selection pane: every object on the slide, front-most first, groups expanded with their +//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among +//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move +//! through the list. Everything it changes goes through engine commands. + +use std::collections::HashSet; + +use deckcraft_model::Shape; +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; +use serde_json::json; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +pub const ROW_H: f32 = 24.0; +const INDENT: f32 = 14.0; + +/// Pane-only state (nothing here is part of the document). +#[derive(Debug, Default)] +pub struct State { + /// Groups folded shut. + pub collapsed: HashSet, + /// Object being renamed and the text typed so far. + pub renaming: Option<(u32, String)>, + rename_started: bool, + /// Last row clicked without Shift (the start of a Shift+click run). + pub anchor: Option, + /// Row being dragged and the drop it would make: (index among siblings, line y). + pub drag: Option<(u32, Option<(usize, f32)>)>, +} + +/// One line of the list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + pub id: u32, + pub name: String, + pub depth: usize, + pub group: bool, + pub hidden: bool, + /// Inside a hidden group: drawn dimmed. + pub dimmed: bool, + pub locked: bool, + pub parent: Option, +} + +/// The rows as listed: front-most first, children under their group unless it's folded. +pub fn rows(shapes: &[Shape], collapsed: &HashSet) -> Vec { + fn rec(v: &[Shape], depth: usize, parent: Option, dim: bool, collapsed: &HashSet, out: &mut Vec) { + if depth > 64 { + return; + } + for sh in v.iter().rev() { + out.push(Row { + id: sh.id.0, + name: sh.name.clone(), + depth, + group: sh.is_group(), + hidden: sh.hidden, + dimmed: dim, + locked: sh.locked, + parent, + }); + if sh.is_group() && !collapsed.contains(&sh.id.0) { + rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out); + } + } + } + let mut out = vec![]; + rec(shapes, 0, None, false, collapsed, &mut out); + out +} + +/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's +/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index +/// among the siblings in the file's order (0 = back) and the y of the insertion line. +pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> { + let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect(); + let first = others.first()?; + let mut gaps: Vec = vec![first.1.top()]; + gaps.extend(others.iter().map(|(_, r)| r.bottom())); + let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?; + // `g` siblings sit above the line; the dragged object goes behind them. + Some((others.len() - g, *gy)) +} + +fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) { + let _ = app.run(id, p); +} + +/// Each row is a widget with an id of its own (keyboard focus sits on the selected row). +fn row_id(id: u32) -> Id { + Id::new(("selection_pane_row", id)) +} + +fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + if enabled && resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled); + resp.on_hover_text(label) +} + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let rows = rows(st.shapes(), &app.selpane.collapsed); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + let has_sel = !selected.is_empty(); + + ui.horizontal(|ui| { + if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.showAll", json!({})); + } + if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.hideAll", json!({})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() { + run(app, "arrange.bringForward", json!({})); + } + }); + }); + ui.add_space(6.0); + + // The list has the keyboard while one of its rows has focus. + let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id)); + if let Some(f) = focused_row { + ui.memory_mut(|m| { + m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + } + let mut rects: Vec = Vec::with_capacity(rows.len()); + let width = ui.available_width(); + for (k, row) in rows.iter().enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); + let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag()); + rects.push(r); + let sel = selected.contains(&row.id); + resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name)); + let painter = ui.painter_at(r); + if sel { + painter.rect_filled(r, CornerRadius::same(3), t.selected); + painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside); + } else if resp.hovered() { + painter.rect_filled(r, CornerRadius::same(3), t.hover); + } + let x0 = r.min.x + 4.0 + INDENT * row.depth as f32; + // Group chevron. + if row.group { + let open = !app.selpane.collapsed.contains(&row.id); + let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click()); + let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); + if cresp.clicked() { + if open { + app.selpane.collapsed.insert(row.id); + } else { + app.selpane.collapsed.remove(&row.id); + } + } + } + let name_x = x0 + 16.0; + let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0)); + let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0)); + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0)); + let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone()); + if let Some(mut buf) = renaming { + let edit_id = ui.id().with(("rename", row.id)); + let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0))); + if !app.selpane.rename_started { + out.request_focus(); + if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())); + s.cursor.set_char_range(Some(all)); + s.store(ui.ctx(), edit_id); + } + app.selpane.rename_started = true; + } + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if out.lost_focus() || enter || esc { + app.selpane.renaming = None; + app.selpane.rename_started = false; + if !esc && buf.trim() != row.name && !buf.trim().is_empty() { + run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()})); + } + ui.memory_mut(|m| m.request_focus(row_id(row.id))); + } else if let Some((_, s)) = app.selpane.renaming.as_mut() { + *s = buf; + } + } else { + let color = if row.dimmed { t.text_faint } else { t.text }; + painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color); + } + // Eye and padlock. + let ink = if row.dimmed { t.text_faint } else { t.text_dim }; + icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false); + icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false); + let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click()); + let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name); + eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label)); + let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click()); + let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name); + lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label)); + if eye_resp.clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + } else if lock_resp.clicked() { + run(app, "shape.lock", json!({"id": row.id})); + } else if resp.double_clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + } else if resp.clicked() { + let m = ui.input(|i| i.modifiers); + click(app, &rows, k, m.command || m.ctrl, m.shift); + ui.memory_mut(|mem| mem.request_focus(row_id(row.id))); + } + if resp.drag_started() { + if !sel { + run(app, "selection.select", json!({"id": row.id})); + } + app.selpane.drag = Some((row.id, None)); + } + context_menu(app, &resp, row); + } + + // Dragging: the insertion line among the dragged row's siblings. + if let Some((dragged, _)) = app.selpane.drag { + let pointer = ui.input(|i| i.pointer.interact_pos()); + let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down()); + let target = pointer.and_then(|p| { + let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?; + let blocks = sibling_blocks(&rows, &rects, parent); + drop_target(dragged, &blocks, p.y) + }); + app.selpane.drag = Some((dragged, target)); + if let Some((_, y)) = target { + let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width); + let p = ui.painter(); + let s = Stroke::new(2.0, t.accent); + p.line_segment([pos2(x0, y), pos2(x1, y)], s); + p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s); + p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s); + } + if released { + app.selpane.drag = None; + if let Some((index, _)) = target { + run(app, "arrange.reorder", json!({"id": dragged, "index": index})); + } + } + } + + // Keys while the list has focus. + if let Some(f) = focused_row + && app.selpane.renaming.is_none() + { + keys(app, ui, &rows, &selected, f); + } + // Clicking the empty part of the list doesn't change anything; keep the pane's height. + let rest = ui.available_size(); + if rest.y > 0.0 { + ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover()); + } +} + +/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom. +fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option) -> Vec<(u32, Rect)> { + let mut out: Vec<(u32, Rect)> = vec![]; + let depth_of = |id: Option| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0); + let depth = depth_of(parent); + let mut current: Option<(u32, Rect)> = None; + for (row, rect) in rows.iter().zip(rects) { + if row.parent == parent { + if let Some(c) = current.take() { + out.push(c); + } + current = Some((row.id, *rect)); + } else if row.depth > depth + && let Some((_, r)) = current.as_mut() + { + *r = r.union(*rect); + } else if let Some(c) = current.take() { + out.push(c); + } + } + if let Some(c) = current { + out.push(c); + } + out +} + +fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) { + let Some(row) = rows.get(k) else { return }; + if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) { + // A run of rows of the same kind (top level, or inside groups) between the anchor and here. + let (lo, hi) = if a <= k { (a, k) } else { (k, a) }; + let top = row.parent.is_none(); + let ids: Vec = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect(); + run(app, "selection.select", json!({"ids": ids, "add": ctrl})); + return; + } + run(app, "selection.select", json!({"id": row.id, "toggle": ctrl})); + app.selpane.anchor = Some(row.id); +} + +fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) { + let (up, down, home, end, f2, esc) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::Home), + i.key_pressed(egui::Key::End), + i.key_pressed(egui::Key::F2), + i.key_pressed(egui::Key::Escape), + ) + }); + let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id)); + let to = if up { + Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0)) + } else if down { + Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0)) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some(k) = to + && let Some(r) = rows.get(k) + && Some(k) != cur + { + run(app, "selection.select", json!({"id": r.id})); + app.selpane.anchor = Some(r.id); + ui.memory_mut(|m| m.request_focus(row_id(r.id))); + } + if f2 + && let [one] = selected + && let Some(r) = rows.iter().find(|r| r.id == *one) + { + app.selpane.renaming = Some((r.id, r.name.clone())); + app.selpane.rename_started = false; + } + if esc { + ui.memory_mut(|m| m.surrender_focus(row_id(focused))); + } +} + +fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { + resp.context_menu(|ui| { + for (label, cmd) in [ + ("Bring to Front", "arrange.bringToFront"), + ("Bring Forward", "arrange.bringForward"), + ("Send Backward", "arrange.sendBackward"), + ("Send to Back", "arrange.sendToBack"), + ] { + if ui.button(label).clicked() { + run(app, cmd, json!({"ids": [row.id]})); + ui.close(); + } + } + ui.separator(); + if ui.button("Rename").clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + ui.close(); + } + if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + ui.close(); + } + if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() { + run(app, "shape.lock", json!({"id": row.id})); + ui.close(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(id: u32, top: f32) -> (u32, Rect) { + (id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H))) + } + + #[test] + fn drop_target_picks_the_nearest_gap() { + // Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1. + let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)]; + // Above everything: in front of all three others. + assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0))); + // Between 4 and 3: one sibling above → index 2. + assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0))); + // Below the last other row: the back. + assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0))); + // Nothing else to drop among. + assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None); + } +} + +#[cfg(test)] +#[path = "selpane_tests.rs"] +mod ui_tests; diff --git a/crates/ui-egui/src/selpane_tests.rs b/crates/ui-egui/src/selpane_tests.rs new file mode 100644 index 0000000..2c2181d --- /dev/null +++ b/crates/ui-egui/src/selpane_tests.rs @@ -0,0 +1,255 @@ +//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye, +//! the padlock, renaming and the keyboard. + +use deckcraft_engine::Session; +use egui::{Modifiers, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open. +fn harness() -> (Harness<'static, SlideApp>, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = vec![]; + for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap(); + ids.push(id); + } + let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + ids.push(g); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("selection".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +fn names(h: &Harness<'_, SlideApp>) -> String { + fn rec(v: &[deckcraft_model::Shape]) -> String { + v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::>().join(" ") + } + rec(h.state().session.active().unwrap().shapes()) +} + +fn selected(h: &Harness<'_, SlideApp>) -> Vec { + let st = h.state().session.active().unwrap(); + st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect() +} + +fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect { + h.get_by_label(name).rect() +} + +fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape { + let st = h.state().session.active().unwrap(); + let mut found = None; + deckcraft_model::walk(st.shapes(), &mut |s, _| { + if found.is_none() && s.name == name { + found = Some(s); + } + }); + found.unwrap() +} + +#[test] +fn lists_front_most_first_with_groups_expanded() { + let (h, _) = harness(); + let ys: Vec = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect(); + // G is in front, then its children front-most first (E above D), then C, B, A. + let order = ["G", "E", "D", "C", "B", "A"]; + let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect(); + by_y.sort_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(by_y.iter().map(|x| x.0).collect::>(), order); + // Children are indented under the group (the name's row starts at the same x; the group + // has a fold button). + assert!(h.query_by_label("Collapse G").is_some()); + assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some()); +} + +#[test] +fn click_ctrl_click_and_shift_click_select() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["A"]); + h.get_by_label("C").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["A", "C"]); + h.get_by_label("A").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // Shift+click: the run from the last click (C) down to A. + h.get_by_label("C").click(); + h.run_steps(2); + h.get_by_label("A").click_modifiers(Modifiers::SHIFT); + h.run_steps(2); + let mut sel = selected(&h); + sel.sort(); + assert_eq!(sel, ["A", "B", "C"]); + // A child of the group. + h.get_by_label("D").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); +} + +#[test] +fn eye_and_padlock_toggle_and_undo() { + let (mut h, _) = harness(); + h.get_by_label("Hide B").click(); + h.run_steps(2); + assert!(shape(&h, "B").hidden); + assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it"); + h.get_by_label("Lock G").click(); + h.run_steps(2); + assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children"); + h.get_by_label("Unlock D").click(); + h.run_steps(2); + assert!(!shape(&h, "D").locked && shape(&h, "G").locked); + h.get_by_label("Hide All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden)); + h.get_by_label("Show All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(shape(&h, "A").hidden); +} + +/// Drag a row with the mouse: press, move in steps, release. +fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.hover_at(from + (to - from) * t); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +#[test] +fn drag_and_drop_restacks_among_siblings() { + let (mut h, _) = harness(); + assert_eq!(names(&h), "A B C G[D E]"); + // A (bottom row) dragged above G (top row): A goes to the front. + let (a, g) = (row(&h, "A"), row(&h, "G")); + drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0)); + assert_eq!(names(&h), "B C G[D E] A"); + assert_eq!(selected(&h), ["A"], "the dragged object is selected"); + // C dragged to the bottom of the list: the back. + let (c, b) = (row(&h, "C"), row(&h, "B")); + drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0)); + assert_eq!(names(&h), "C B G[D E] A"); + // D (a child) dragged far above its group stays in the group, at its front. + let (d, a) = (row(&h, "D"), row(&h, "A")); + drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0)); + assert_eq!(names(&h), "C B G[E D] A"); + // B dropped over the group lands among the top-level objects (here just in front of the + // group), never inside it. + let (b, g) = (row(&h, "B"), row(&h, "G")); + drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0)); + assert_eq!(names(&h), "C G[E D] B A"); + // Each drop is one undo step. + for _ in 0..4 { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + assert_eq!(names(&h), "A B C G[D E]"); +} + +#[test] +fn bring_forward_and_send_backward_buttons() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); + h.get_by_label("D").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group"); + h.get_by_label("Send Backward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); +} + +#[test] +fn double_click_renames_in_place() { + let (mut h, _) = harness(); + let b = row(&h, "B").center(); + h.hover_at(b); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(2); + assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming"); + // The whole name is selected: typing replaces it. + h.event(egui::Event::Text("Bravo".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id); + assert!(h.state().selpane.renaming.is_none()); + // Escape cancels. + h.get_by_label("Bravo").click(); + h.run_steps(2); + h.key_press(egui::Key::F2); + h.run_steps(3); + assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object"); + h.event(egui::Event::Text("Nope".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Bravo").is_some()); + assert!(h.query_by_label("Nope").is_none()); +} + +#[test] +fn arrow_keys_move_through_the_list_and_collapse_hides_children() { + let (mut h, _) = harness(); + h.get_by_label("G").click(); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["E"]); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // The arrows moved through the list: C itself didn't move on the slide. + assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0)); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); + h.get_by_label("Collapse G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some()); + h.get_by_label("Expand G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_some()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07ae91c..7ac7318 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | -| Selection Pane | yes | yes | +| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | @@ -89,4 +89,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | + +| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |