mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #596 from NikitkaRa: SmartArt: Text Pane, add and remove shapes, promote and demote
# Conflicts: # crates/engine/src/tests.rs # crates/ui-egui/src/panes.rs
This commit is contained in:
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -761,92 +761,13 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
let kind = str_param(p, "kind").unwrap_or("process").to_string();
|
||||
let items: Vec<String> =
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,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;
|
||||
@@ -132,6 +133,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(background::specs());
|
||||
v.extend(arrange::specs());
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<Item = &'a str>) -> Vec<DiagramNode> {
|
||||
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<u8> = 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<DiagramNode>, index: Option<usize>, 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<DiagramNode>, 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<Vec<usize>> {
|
||||
if d.kind == "hierarchy" {
|
||||
return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect();
|
||||
}
|
||||
let mut out: Vec<Vec<usize>> = 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<Shape> {
|
||||
let map = node_map(d);
|
||||
let groups: Vec<Vec<&DiagramNode>> = 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<Shape> {
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
if nodes.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut stack: Vec<usize> = 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<usize>| -> Vec<usize> { (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<usize> = (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::<usize>().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<Xfrm> = (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<Shape> = 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<ShapeId> {
|
||||
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<Shape> {
|
||||
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<T>(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result<T>) -> Result<T> {
|
||||
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<usize> {
|
||||
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<DiagramNode>) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
const CMD: &str = "smartArt.setNodes";
|
||||
let given: Option<Vec<DiagramNode>> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<u8> {
|
||||
n.iter().map(|x| x.level).collect()
|
||||
}
|
||||
fn mk(levels: &[u8]) -> Vec<DiagramNode> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1679,3 +1679,52 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
||||
}
|
||||
|
||||
#[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<u64> = 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)));
|
||||
}
|
||||
|
||||
@@ -327,6 +327,26 @@ pub struct Shape {
|
||||
/// Unmodelled XML kept for round-trip (extension lists etc.).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
/// 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<Diagram>,
|
||||
}
|
||||
|
||||
/// 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<DiagramNode>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -352,6 +372,7 @@ impl Default for Shape {
|
||||
click: None,
|
||||
hover: None,
|
||||
raw_ext: None,
|
||||
diagram: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,6 +820,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::<Vec<_>>()}));
|
||||
app.ui.pane = Some("smartart".into());
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
@@ -88,7 +88,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<String>,
|
||||
pub pane_width: f32,
|
||||
pub ruler: bool,
|
||||
@@ -103,6 +104,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<usize>,
|
||||
/// The Text Pane's line drafts, for the SmartArt group they were read from.
|
||||
pub smartart_drafts: (u32, Vec<String>),
|
||||
pub grayscale: bool,
|
||||
/// Comment the Review Previous/Next buttons last landed on: `(slide, index)`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -131,6 +138,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,
|
||||
comment_focus: None,
|
||||
}
|
||||
@@ -163,7 +173,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}"),
|
||||
|
||||
@@ -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<String> = 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<usize> = None;
|
||||
let mut focus_row: Option<usize> = 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);
|
||||
}
|
||||
@@ -923,3 +1014,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "smartart_tests.rs"]
|
||||
mod smartart_tests;
|
||||
|
||||
@@ -125,6 +125,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");
|
||||
}
|
||||
@@ -223,6 +226,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),
|
||||
_ => {}
|
||||
@@ -2579,6 +2583,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
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
|
||||
| SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save |
|
||||
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) |
|
||||
| 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 |
|
||||
|
||||
+8
-4
@@ -211,11 +211,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**.
|
||||
|
||||
@@ -12,6 +12,8 @@ The summary between `<!-- SUMMARY -->
|
||||
| P2 | 54 | 13 | 15 | 26 | 38% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 16 | 25 | 39% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 16 | 25 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
@@ -26,7 +28,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Text | 80% |
|
||||
| Tables | 89% |
|
||||
| Charts | 65% |
|
||||
| SmartArt | 33% |
|
||||
| SmartArt | 50% |
|
||||
| Pictures | 48% |
|
||||
| Media | 67% |
|
||||
| Transitions | 92% |
|
||||
@@ -179,7 +181,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 |
|
||||
|
||||
@@ -194,7 +194,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, 3-D models and stock content, 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) |
|
||||
|
||||
Reference in New Issue
Block a user