From 6e403354247e419cd9fe52be241e84ce57c58c1a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:22:22 +0300 Subject: [PATCH] SmartArt: Text Pane, add and remove shapes, promote and demote A SmartArt group now keeps its outline (Shape.diagram: layout + nodes with levels) and its shapes are regenerated from it inside the group's child rectangle. New commands: smartArt.nodes, setNodes, addNode (after, before, above, below), removeNode, promote, demote and layout. Sub-points become bullets in their parent's shape; the hierarchy layout is a real tree with connector lines. Text typed into a diagram shape is read back first. UI: a SmartArt Design contextual tab and a Text Pane (Enter adds a line, Tab/Shift+Tab demote/promote, Backspace on an empty line removes it). Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 95 +---- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/smartart.rs | 603 +++++++++++++++++++++++++++ crates/engine/src/tests.rs | 49 +++ crates/model/src/lib.rs | 21 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/panes.rs | 95 +++++ crates/ui-egui/src/ribbon.rs | 51 +++ crates/ui-egui/src/smartart_tests.rs | 53 +++ docs/file-format-parity.md | 2 +- docs/gaps.md | 12 +- docs/parity-checklist.md | 6 +- docs/target-app-parity.md | 2 +- 14 files changed, 908 insertions(+), 98 deletions(-) create mode 100644 crates/engine/src/cmd/smartart.rs create mode 100644 crates/ui-egui/src/smartart_tests.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..8efa7f2a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "SmartArt", ["Insert", "Illustrations"], None, - "{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}", + "{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}", has_slide, smart_art ), @@ -709,92 +709,13 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { }) } -/// SmartArt-style graphics built from shapes (grouped), with our own layouts. +/// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with +/// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); - let items: Vec = - p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); - let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items }; - let n = items.len().clamp(1, 12); - let size = s.doc()?.doc.slide_size; - let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); - let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; - let mut shapes: Vec<(String, Xfrm, usize)> = vec![]; - match kind.as_str() { - "cycle" => { - let r = area.h.min(area.w) * 0.38; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let d = r * 0.62; - for i in 0..n { - let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i)); - } - } - "hierarchy" => { - let w = area.w / (n.max(2) - 1) as f64 * 0.8; - shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0)); - for i in 1..n { - let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64; - shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i)); - } - } - "pyramid" => { - let h = area.h / n as f64; - for i in 0..n { - let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; - shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i)); - } - } - "matrix" => { - let (w, h) = (area.w / 2.0, area.h / 2.0); - for i in 0..n.min(4) { - shapes.push(( - "roundRect".into(), - Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0), - i, - )); - } - } - "list" => { - let h = area.h / n as f64; - for i in 0..n { - shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i)); - } - } - _ => { - let gap = 24.0; - let w = (area.w - gap * (n as f64 - 1.0)) / n as f64; - for i in 0..n { - shapes.push(( - if i + 1 < n { "homePlate".into() } else { "roundRect".into() }, - Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), - i, - )); - } - } - } - let mut ids = vec![]; - for (preset, x, i) in shapes { - let mut sh = Shape { - xfrm: Some(x), - geom: Geom::preset(&preset), - style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))), - ..Default::default() - }; - let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or("")); - body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); - for para in &mut body.paragraphs { - para.props.align = Some(deckcraft_model::text::Align::Center); - for r in &mut para.runs { - r.props.size = Some(20.0); - } - } - sh.text = Some(body); - ids.push(add_shape(s, sh, false)?.0); - } - let g = s.execute("arrange.group", &json!({"ids": ids}))?; - if let Some(gid) = g.get("id").and_then(Value::as_u64) { - let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")})); - } - Ok(g) + let nodes = match p.get("nodes") { + Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?, + None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)), + }; + super::smartart::insert(s, &kind, nodes) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..e1b8eb4d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod review; pub mod selection; pub mod shape; pub mod slide; +pub mod smartart; pub mod table; pub mod text; pub mod transition; @@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(slide::specs()); v.extend(insert::specs()); + v.extend(smartart::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); v.extend(selection::specs()); diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs new file mode 100644 index 00000000..0993e87d --- /dev/null +++ b/crates/engine/src/cmd/smartart.rs @@ -0,0 +1,603 @@ +//! SmartArt: a diagram's outline (the Text Pane) and the shapes generated from it. +//! +//! A SmartArt graphic is a group whose `diagram` holds its layout and its nodes (text + outline +//! level). Every edit changes the nodes and regenerates the group's children inside the group's +//! child rectangle, so the diagram keeps its size and place. Text typed straight into a diagram +//! shape is read back into the nodes before each regeneration. + +use deckcraft_color::SchemeSlot; +use deckcraft_geom::Xfrm; +use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; +use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle}; +use serde_json::{Value, json}; + +use super::*; +use crate::{EngineError, Result, Session}; + +/// Deepest outline level the Text Pane allows. +pub const MAX_LEVEL: u8 = 8; +/// Most top-level shapes a layout draws. +const MAX_SHAPES: usize = 40; + +pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "smartArt.nodes", + "Text Pane", + ["SmartArt Design", "Create Graphic"], + None, + "{id?} → {id, kind, nodes: [{text, level}]} (id: the SmartArt group or one of its shapes; default the selection)", + has_selection, + nodes + ), + cmd!( + "smartArt.setNodes", + "Edit SmartArt Text", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?} (index + text edits one node)", + has_selection, + set_nodes + ), + cmd!( + "smartArt.addNode", + "Add Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)", + has_selection, + add_node + ), + cmd!( + "smartArt.removeNode", + "Remove Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index} (its sub-points move up one level)", + has_selection, + remove_node + ), + cmd!("smartArt.promote", "Promote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, promote), + cmd!("smartArt.demote", "Demote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, demote), + cmd!( + "smartArt.layout", + "Change Layout", + ["SmartArt Design", "Layouts"], + None, + "{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}", + has_selection, + change_layout + ), + ] +} + +/// Nodes from text lines: each leading tab is one outline level. +pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { + lines + .into_iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = l.chars().take_while(|c| *c == '\t').count(); + DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL) } + }) + .collect() +} + +/// Levels made consistent: the first node is top level and no node is more than one level +/// deeper than the node before it. +pub fn normalize(nodes: &mut [DiagramNode]) { + let mut prev: Option = None; + for n in nodes.iter_mut() { + n.level = match prev { + None => 0, + Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), + }; + prev = Some(n.level); + } +} + +/// Index one past the last descendant of node `i`. +fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { + let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); + let mut j = i + 1; + while nodes.get(j).is_some_and(|n| n.level > lv) { + j += 1; + } + j +} + +/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the +/// new node's index. +pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { + let Some(i) = index.filter(|i| *i < nodes.len()) else { + nodes.push(DiagramNode { text: text.into(), level: 0 }); + return nodes.len() - 1; + }; + let lv = nodes[i].level; + let (at, level) = match position { + "before" => (i, lv), + "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), + "above" => { + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level = (n.level + 1).min(MAX_LEVEL); + } + (i, lv) + } + _ => (subtree_end(nodes, i), lv), + }; + nodes.insert(at, DiagramNode { text: text.into(), level }); + at +} + +/// Remove node `i`; its sub-points move up one level. +pub fn remove(nodes: &mut Vec, i: usize) -> bool { + if i >= nodes.len() { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i + 1..end] { + n.level = n.level.saturating_sub(1); + } + nodes.remove(i); + normalize(nodes); + true +} + +/// Move node `i` and its sub-points one level up. False when it is already top level. +pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + if nodes.get(i).is_none_or(|n| n.level == 0) { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level -= 1; + } + true +} + +/// Move node `i` and its sub-points one level down, under the node before it. False when there is +/// nothing to go under. +pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; + if cur.level > prev.level || cur.level >= MAX_LEVEL { + return false; + } + let end = subtree_end(nodes, i); + if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { + return false; + } + for n in &mut nodes[i..end] { + n.level += 1; + } + true +} + +/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none). +fn node_map(d: &Diagram) -> Vec> { + if d.kind == "hierarchy" { + return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect(); + } + let mut out: Vec> = vec![]; + for (i, n) in d.nodes.iter().enumerate() { + match out.last_mut() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES }; + out.truncate(cap); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +fn sync_text(d: &mut Diagram, children: &[Shape]) { + for (shape, idx) in children.iter().zip(node_map(d)) { + let Some(body) = &shape.text else { continue }; + if body.paragraphs.len() != idx.len() { + continue; + } + for (p, i) in body.paragraphs.iter().zip(idx) { + if let Some(n) = d.nodes.get_mut(i) { + n.text = p.text(); + } + } + } +} + +fn slot(i: usize) -> SchemeSlot { + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] +} + +fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape { + let mut body = TextBody::default(); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let top = lines.first().map(|n| n.level).unwrap_or(0); + for (k, n) in lines.iter().enumerate() { + let mut para = Paragraph::new(&n.text); + if k == 0 { + para.props.align = Some(Align::Center); + } else { + para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); + para.props.align = Some(Align::Left); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-10.0); + } + let size = if k == 0 { 20.0 } else { 14.0 }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + Shape { + name: format!("SmartArt shape {}", color + 1), + xfrm: Some(x), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(slot(color))), + text: Some(body), + ..Default::default() + } +} + +fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { + let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); + x.flip_h = (b.0 < a.0) != (b.1 < a.1); + Shape { + name: "SmartArt connector".into(), + xfrm: Some(x), + geom: Geom::preset("line"), + style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), + fill: Some(Fill::None), + ..Default::default() + } +} + +/// The diagram's shapes laid out in `area` (ids not yet assigned). +pub fn layout(d: &Diagram, area: Xfrm) -> Vec { + let map = node_map(d); + let groups: Vec> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect(); + let n = groups.len().max(1); + let mut out = vec![]; + match d.kind.as_str() { + "hierarchy" => return hierarchy(d, area), + "cycle" => { + let r = area.h.min(area.w) * 0.38; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = r * 0.62; + for (i, g) in groups.iter().enumerate() { + let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); + out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g)); + } + } + "pyramid" => { + let h = area.h / n as f64; + for (i, g) in groups.iter().enumerate() { + let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; + out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g)); + } + } + "matrix" => { + let (w, h) = (area.w / 2.0, area.h / 2.0); + for (i, g) in groups.iter().enumerate() { + let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0); + out.push(text_shape("roundRect", x, i, g)); + } + } + "list" => { + let h = area.h / n as f64; + for (i, g) in groups.iter().enumerate() { + out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g)); + } + } + _ => { + let gap = 24.0; + let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0); + for (i, g) in groups.iter().enumerate() { + let preset = if i + 1 < n { "homePlate" } else { "roundRect" }; + out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g)); + } + } + } + out +} + +/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. +fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + let depth: Vec = (0..nodes.len()) + .map(|i| { + let (mut d, mut p) = (0, parent[i]); + while let Some(q) = p { + d += 1; + p = parent[q]; + } + d + }) + .collect(); + let rows = depth.iter().max().copied().unwrap_or(0) + 1; + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = (cell_w * 0.85, row_h * 0.6); + // Left edge (in leaves) of each node's span. + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) + }) + .collect(); + let mut out: Vec = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect(); + for i in 0..nodes.len() { + if let Some(p) = parent[i] { + let (a, b) = (boxes[p], boxes[i]); + out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))); + } + } + out +} + +/// The SmartArt group that `id` is, or is inside. +fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + for sh in shapes { + if sh.id == id && sh.diagram.is_some() { + return Some(sh); + } + if let ShapeKind::Group { children, .. } = &sh.kind { + if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { + return Some(sh); + } + if let Some(g) = diagram_group(children, id) { + return Some(g); + } + } + } + None +} + +/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. +pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; + let ids = targets(s, p)?; + ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) +} + +/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. +fn regenerate(doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout(d, area); + for sh in &mut v { + sh.id = ShapeId(doc.alloc_id()); + } + v +} + +/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. +fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { + let gid = target_group(s, p, cmd)?; + s.edit(|doc, sel| { + let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; + let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; + sync_text(&mut d, &children); + let out = f(&mut d)?; + normalize(&mut d.nodes); + let kids = regenerate(doc, &d, child); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; + sh.kind = ShapeKind::Group { children: kids, child }; + sh.diagram = Some(d); + sel.shapes = vec![gid]; + sel.text = None; + Ok(out) + }) +} + +fn index(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) +} + +/// Insert a new SmartArt graphic (`insert.smartArt`). +pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { + let kind = if KINDS.contains(&kind) { kind } else { "process" }; + if nodes.is_empty() { + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); + } + normalize(&mut nodes); + let d = Diagram { kind: kind.into(), nodes }; + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let id = s.edit(|doc, sel| { + let children = regenerate(doc, &d, area); + let id = ShapeId(doc.alloc_id()); + let g = Shape { + id, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +fn nodes(s: &mut Session, p: &Value) -> Result { + let gid = target_group(s, p, "smartArt.nodes")?; + let g = shape_of(s, gid)?; + let mut d = g.diagram.unwrap_or_default(); + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) +} + +fn set_nodes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.setNodes"; + let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { + (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), + (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), + _ => None, + }; + let one = p.get("index").map(|_| index(p, CMD)).transpose()?; + let text = str_param(p, "text").map(String::from); + edit_diagram(s, p, CMD, |d| { + if let Some(n) = given { + d.nodes = n; + } + if let (Some(i), Some(t)) = (one, text) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; + } + Ok(Value::Null) + }) +} + +fn add_node(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + let pos = str_param(p, "position").unwrap_or("after").to_string(); + let text = str_param(p, "text").unwrap_or("").to_string(); + edit_diagram(s, p, "smartArt.addNode", |d| { + let at = add(&mut d.nodes, i, &pos, &text); + Ok(json!({"index": at})) + }) +} + +fn remove_node(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.removeNode"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) +} + +fn promote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.promote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) +} + +fn demote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.demote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) +} + +fn change_layout(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.layout"; + let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + d.kind = kind; + Ok(Value::Null) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lv(n: &[DiagramNode]) -> Vec { + n.iter().map(|x| x.level).collect() + } + fn mk(levels: &[u8]) -> Vec { + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect() + } + + #[test] + fn outline_edits() { + let mut n = mk(&[0, 1, 1, 0]); + assert_eq!(add(&mut n, Some(0), "after", "x"), 3, "after the node's sub-points"); + assert_eq!(lv(&n), [0, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "below", "y"), 3); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "above", "z"), 0); + assert_eq!(lv(&n), [0, 1, 2, 2, 2, 0, 0]); + assert!(remove(&mut n, 1)); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert!(!promote_node(&mut n, 0)); + assert!(!demote_node(&mut n, 0), "nothing above the first node"); + assert!(!demote_node(&mut n, 2) || lv(&n)[2] == 2); + let mut m = mk(&[0, 0, 1]); + assert!(demote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 1, 2], "sub-points move with their node"); + assert!(promote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 0, 1]); + let mut bad = mk(&[2, 4, 0]); + normalize(&mut bad); + assert_eq!(lv(&bad), [0, 1, 0]); + assert_eq!(lv(&nodes_from_lines(["A", "\tB", "\t\tC", "", "D"])), [0, 1, 2, 0]); + } + + #[test] + fn layouts_map_nodes_to_shapes() { + let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); + let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]) }; + let v = layout(&d, area); + assert_eq!(v.len(), 2); + let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); + assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape"); + let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]) }; + let v = layout(&h, area); + assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link"); + for sh in &v { + let x = sh.xfrm.unwrap_or_default(); + assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); + } + assert!(layout(&Diagram { kind: "cycle".into(), nodes: vec![] }, area).is_empty()); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..9d725f88 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,52 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn smartart_text_pane_edits_regenerate_the_diagram() { + let mut s = session(); + let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap(); + let gid = r["id"].as_u64().unwrap(); + let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) { + Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(), + _ => panic!("not a group"), + }; + assert_eq!(kids(&s).len(), 2, "Scope is a bullet inside Plan"); + let box_before = s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm; + + // Type into the second shape directly, then add a node: the typed text is kept. + let build = kids(&s)[1].id.0; + s.execute("text.edit", &json!({"id": build, "end": true})).unwrap(); + s.execute("text.insert", &json!({"text": " it"})).unwrap(); + let r = s.execute("smartArt.addNode", &json!({"id": gid, "index": 2, "position": "after", "text": "Ship"})).unwrap(); + assert_eq!(r["index"], 3); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let texts: Vec<&str> = n["nodes"].as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); + assert_eq!(texts, ["Plan", "Scope", "Build it", "Ship"]); + assert_eq!(kids(&s).len(), 3); + + // Demote Ship under Build, promote Scope, remove Plan. + s.execute("smartArt.demote", &json!({"id": gid, "index": 3})).unwrap(); + assert_eq!(kids(&s).len(), 2); + s.execute("smartArt.promote", &json!({"id": gid, "index": 1})).unwrap(); + assert_eq!(kids(&s).len(), 3); + assert!(s.execute("smartArt.promote", &json!({"id": gid, "index": 0})).is_err()); + s.execute("smartArt.removeNode", &json!({"id": gid, "index": 0})).unwrap(); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let lv: Vec = n["nodes"].as_array().unwrap().iter().map(|x| x["level"].as_u64().unwrap()).collect(); + assert_eq!(lv, [0, 0, 1]); + + // A child shape also finds its diagram; layouts switch; the box stays put; undo works. + let child = kids(&s)[0].id.0; + s.execute("smartArt.layout", &json!({"id": child, "kind": "hierarchy"})).unwrap(); + assert_eq!(kids(&s).len(), 3 + 1, "three boxes and one link"); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm, box_before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kids(&s).len(), 2); + + // The outline survives a .deckcraft save. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let bytes = deckcraft_format::save(&doc).unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3))); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..bdd52a63 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -320,6 +320,26 @@ pub struct Shape { /// Unmodelled XML kept for round-trip (extension lists etc.). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, + /// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagram: Option, +} + +/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Diagram { + /// list, process, cycle, hierarchy, pyramid or matrix. + pub kind: String, + pub nodes: Vec, +} + +/// One line of the Text Pane: its text and outline level (0 = top level). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DiagramNode { + pub text: String, + pub level: u8, } impl Default for Shape { @@ -345,6 +365,7 @@ impl Default for Shape { click: None, hover: None, raw_ext: None, + diagram: None, } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..2c685540 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -683,6 +683,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "Insert"); if ok { run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); } ok || cancel } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..6b2f0e33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,7 +86,8 @@ pub struct UiState { pub notes: bool, pub notes_height: f32, pub thumbs_width: f32, - /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`. + /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, + /// `smartart` (Text Pane). pub pane: Option, pub pane_width: f32, pub ruler: bool, @@ -101,6 +102,12 @@ pub struct UiState { pub ui_scale: f32, /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). pub format_tab: String, + /// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it). + pub smartart_row: usize, + /// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list). + pub smartart_focus: Option, + /// The Text Pane's line drafts, for the SmartArt group they were read from. + pub smartart_drafts: (u32, Vec), pub grayscale: bool, } @@ -126,6 +133,9 @@ impl Default for UiState { presenter_view: true, ui_scale: 1.0, format_tab: "fill".into(), + smartart_row: 0, + smartart_focus: None, + smartart_drafts: (0, vec![]), grayscale: false, } } @@ -146,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), ("view.fit", "Fit to Window", None, "{}"), - ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), + ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..5088dc0b 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "smartart" => "Text Pane", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,11 +51,101 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "smartart" => smartart(app, ui), _ => {} }); }); } +/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and +/// Shift+Tab demote and promote, Backspace in an empty line removes it. +fn smartart(app: &mut SlideApp, ui: &mut Ui) { + let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { + ui.label("Select a SmartArt graphic to edit its text."); + return; + }; + let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); + let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some(); + if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any { + app.ui.smartart_drafts = (gid.0, texts.clone()); + } + let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1); + let mut action: Option<(&str, Value)> = None; + let mut commit: Option = None; + let mut focus_row: Option = None; + ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(4.0); + for (i, node) in d.nodes.iter().enumerate() { + let Some(draft) = drafts.get_mut(i) else { break }; + let id = egui::Id::new(("smartart_line", gid.0, i)); + ui.horizontal(|ui| { + ui.add_space(16.0 * node.level as f32); + ui.label("•"); + let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY); + let r = ui.add(te); + if app.ui.smartart_focus == Some(i) { + r.request_focus(); + app.ui.smartart_focus = None; + } + if r.gained_focus() || r.has_focus() { + app.ui.smartart_row = i; + } + if r.has_focus() { + let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace)); + if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 { + action = Some(("smartArt.removeNode", json!({"index": i}))); + focus_row = Some(i.saturating_sub(1)); + } + } + if r.lost_focus() { + let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift)); + if tab { + action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i}))); + focus_row = Some(i); + } else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + action = Some(("smartArt.addNode", json!({"index": i, "position": "after"}))); + focus_row = Some(i + 1); + } + commit = Some(i); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("+ Add").clicked() { + action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); + } + if ui.button("Remove").clicked() && !d.nodes.is_empty() { + action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); + } + }); + // Commit a changed line before any structural edit, so the edit sees the new text. + if let Some(i) = commit + && drafts.get(i) != texts.get(i) + { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()})); + } + if action.is_some() { + for (i, t) in drafts.iter().enumerate() { + if Some(i) != commit && texts.get(i) != Some(t) { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t})); + } + } + } + app.ui.smartart_drafts.1 = drafts; + if let Some((cmd, mut p)) = action { + if let Some(o) = p.as_object_mut() { + o.insert("id".into(), json!(gid.0)); + } + if let Ok(r) = app.run(cmd, p) { + let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0); + app.ui.smartart_row = row; + app.ui.smartart_drafts.0 = 0; + app.ui.smartart_focus = Some(row); + } + } +} + fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } @@ -635,3 +726,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +#[cfg(test)] +#[path = "smartart_tests.rs"] +mod smartart_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..c4c31c81 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -122,6 +122,9 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> { v.push("Table Design"); v.push("Layout"); } + if deckcraft_engine::cmd::smartart::selected(st).is_some() { + v.push("SmartArt Design"); + } if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) { v.push("Chart Design"); } @@ -220,6 +223,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "Table Design" => table_design(app, ui), "Layout" => table_layout(app, ui), "Chart Design" => chart_design(app, ui), + "SmartArt Design" => smartart_design(app, ui), "Playback" => playback(app, ui), "Slide Master" => slide_master(app, ui), _ => {} @@ -2318,6 +2322,53 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { arrange_group(app, ui); } +/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. +fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { + let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); + let row = app.ui.smartart_row; + let (gid, n, kind) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone()), + None => (None, 0, String::new()), + }; + let at = row.min(n.saturating_sub(1)); + group(ui, |ui| { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); + } + rows(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (label, pos) in + [("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")] + { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } + } + }); + if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { + run(app, "smartArt.promote", json!({"id": gid, "index": at})); + } + if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { + run(app, "smartArt.demote", json!({"id": gid, "index": at})); + } + }); + }); + group(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for k in deckcraft_engine::cmd::smartart::KINDS { + let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]); + if ui.selectable_label(kind == k, label).clicked() { + run(app, "smartArt.layout", json!({"id": gid, "kind": k})); + } + } + }); + }); + arrange_group(app, ui); +} + fn playback(app: &mut SlideApp, ui: &mut Ui) { let m = app .session diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs new file mode 100644 index 00000000..7692f704 --- /dev/null +++ b/crates/ui-egui/src/smartart_tests.rs @@ -0,0 +1,53 @@ +//! The SmartArt Text Pane driven like a person would: type in a line, Enter for a new line, Tab +//! to demote. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn levels(h: &Harness<'_, SlideApp>) -> Vec<(String, u8)> { + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).unwrap(); + d.nodes.into_iter().map(|n| (n.text, n.level)).collect() +} + +fn line<'a>(h: &'a Harness<'_, SlideApp>, v: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_value(v).find(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput).unwrap() +} + +#[test] +fn text_pane_types_adds_and_demotes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "Build"]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("smartart".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); + assert!(h.query_by_label("Text Pane").is_some()); + + line(&h, "Build").click(); + h.run_steps(2); + h.key_press(egui::Key::End); + line(&h, "Build").type_text(" it"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), (String::new(), 0)]); + + // The new line has the focus: type, then Tab demotes it under "Build it". + h.event(egui::Event::Text("Test".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..d1427027 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it. | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | +| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..b737232c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,11 +150,15 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). -### SmartArt is pre-drawn shapes only -- **Evidence:** `crates/pptx/src/read/shapes.rs` reads SmartArt's drawing part as a group; no text - pane, no layout switching, the diagram data isn't regenerated. +### SmartArt editing is shallow +- **Evidence:** a SmartArt graphic inserted in DeckCraft keeps its outline (`Shape.diagram`) and + has a Text Pane, Add Shape (after, before, above, below), Promote, Demote, Remove and layout + switching between DeckCraft's six layouts (`crates/engine/src/cmd/smartart.rs`). But + `crates/pptx/src/read/shapes.rs` reads a PowerPoint diagram's drawing part as a plain group (no + outline, so no Text Pane), the outline isn't written to `.pptx` (`dgm` data), and there are six + layouts rather than PowerPoint's ~200. - **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 10–16 h. +- **Estimate:** 8–14 h. ### Animation Pane, effect count and preview Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..e251efae 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -24,7 +24,7 @@ The summary between ` | Text | 80% | | Tables | 89% | | Charts | 65% | -| SmartArt | 33% | +| SmartArt | 50% | | Pictures | 48% | | Media | 67% | | Transitions | 92% | @@ -177,7 +177,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Charts | Chart elements (title, legend, labels, axes, gridlines) | P1 | P | | Charts | Chart styles and colours | P2 | P | | SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P | -| SmartArt | Text pane and layout switching | P2 | M | +| SmartArt | Text pane and layout switching | P2 | P | | Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D | | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..0c96188c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -190,7 +190,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | | Tables | 5 | 89% | ~75% | 3–6 | Draw table, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) | | Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) | -| SmartArt | 4 | 33% | ~25% | 10–16 | Text pane, layout switching, the diagram data model on save | [parity-checklist.md](parity-checklist.md) | +| SmartArt | 4 | 50% | ~35% | 8–14 | PowerPoint diagrams editable (read their data model), the data model on save, more layouts | [parity-checklist.md](parity-checklist.md) | | Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) | | Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | | Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) |