Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ddbed09f86 Front-most first with groups folding; drag a row to restack among its siblings (accent
insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and
padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run;
arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu,
greyed handles on locked objects. egui_kittest tests drive the pane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:03:46 -04:00
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
Brandon ThomasandClaude Opus 5.5 ea3b1159d5 Korean, Japanese and Chinese: input-method composition and interface text (#101) (#331)
* Text: input method composition as commands, one undo step per composition (#101)

text.compose shows an input method's preedit in place at the insertion point and replaces it on
each update; an empty preedit cancels and restores the text and selection. text.commit replaces
the composition with the result as one undo step. Any other edit, key or click settles an open
composition first. The composition is underlined under the baseline (thicker under the part being
converted), and the caret line is measured for empty placeholders too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UI: Korean, Japanese and Chinese input and interface text (#101)

The slide turns the input method on while it takes typing, with the candidate window at the
caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes
syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul,
Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun
Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text.
Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before
focus leaves in the same frame (Escape, another slide). Control channel: ui.ime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:03:42 -04:00
19 changed files with 1550 additions and 130 deletions
+2
View File
@@ -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) |
+91 -37
View File
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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"))?;
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);
}
}
}
}
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 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<Value> {
} else {
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).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<Value> {
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;
+14
View File
@@ -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<Value> {
let ids = targets(s, p)?;
let st = s.doc()?;
let free: Vec<u32> = 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<deckcraft_model::Shape>> {
match sel.target {
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
+126
View File
@@ -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<CommandSpec> {
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<Value> {
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<ShapeId> = 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<ShapeId> = 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<ShapeId> = 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<Value> {
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<Value> {
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;
+264
View File
@@ -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<u32>) {
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::<Vec<_>>().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<u32> {
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");
}
+34 -10
View File
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn change(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
})
}
/// 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<Value> {
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<Value> {
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<Value> {
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
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)) {
+12 -4
View File
@@ -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 {
+40
View File
@@ -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<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
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<Option<ShapeId>> {
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
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")]
+20
View File
@@ -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>| 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<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
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());
}
+14 -2
View File
@@ -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") {
+18 -2
View File
@@ -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");
+211
View File
@@ -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<u8> {
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<u8>)> {
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<u8>)]) -> Vec<u8> {
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("<p:nvPr").unwrap_or(rest.len());
&rest[..j]
}
#[test]
fn lock_and_hidden_round_trip() {
let p = deck();
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
let s = &q.slides[0];
for (name, locked, hidden) in [
("Locked", true, false),
("Free", false, false),
("Hidden text", true, true),
("Group", true, false),
("Child locked", true, false),
("Child free", false, false),
("Child hidden", false, true),
("Pic", true, false),
("Conn", true, false),
("Tbl", true, false),
] {
let sh = find(s, name);
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
}
// The stacking order of the slide and of the group survives too.
let names: Vec<&str> = 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!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
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#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
}
/// 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("<p:cNvSpPr").expect("cNvSpPr");
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
};
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
let xml = swap(
xml,
"B",
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
);
let xml =
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
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("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
slide.1 = xml.into_bytes();
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert!(!find(&q.slides[0], "A").locked);
}
+12
View File
@@ -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();
+17 -7
View File
@@ -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);
}
+4
View File
@@ -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(),
}
}
+1 -67
View File
@@ -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<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
ui.horizontal(|ui| {
if ui.button("Show All").clicked() {
let ids: Vec<u32> = 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<u32> = 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() {
+412
View File
@@ -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<u32>,
/// 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<u32>,
/// 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<u32>,
}
/// The rows as listed: front-most first, children under their group unless it's folded.
pub fn rows(shapes: &[Shape], collapsed: &HashSet<u32>) -> Vec<Row> {
fn rec(v: &[Shape], depth: usize, parent: Option<u32>, dim: bool, collapsed: &HashSet<u32>, out: &mut Vec<Row>) {
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<f32> = 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<u32> = 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<Rect> = 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<u32>) -> Vec<(u32, Rect)> {
let mut out: Vec<(u32, Rect)> = vec![];
let depth_of = |id: Option<u32>| 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<u32> = 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;
+255
View File
@@ -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<u32>) {
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::<Vec<_>>().join(" ")
}
rec(h.state().session.active().unwrap().shapes())
}
fn selected(h: &Harness<'_, SlideApp>) -> Vec<String> {
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<f32> = ["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::<Vec<_>>(), 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());
}
+3 -1
View File
@@ -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 |