diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 70e0fa9a..66b8dda4 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -103,6 +103,29 @@ pub const COLORS: [(&str, &str); 17] = [ ("accent6Grad", "Gradient Range - Accent 6"), ]; +/// The gallery category a layout is shown under in Convert to SmartArt and Change Layout. +pub fn category(kind: &str) -> &'static str { + match kind { + "pictureList" | "pictureCaption" | "pictureProcess" | "pictureHList" => "picture", + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "cycle" | "blockCycle" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "pie" => "cycle", + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", + "radial" | "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", + "matrix" | "titledMatrix" | "gridMatrix" | "cycleMatrix" => "matrix", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + _ => "list", + } +} + pub fn is_kind(k: &str) -> bool { LAYOUTS.iter().any(|(id, _)| *id == k) } @@ -1833,6 +1856,13 @@ fn to_text(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.toText"; let gid = target_group(s, p, CMD)?; let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). + let (title_pt, bullet_pt) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + text_sizes(g.children()) + }; let new = s.edit(|doc, sel| { let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; @@ -1841,6 +1871,11 @@ fn to_text(s: &mut Session, p: &Value) -> Result { for n in &d.nodes { let mut para = Paragraph::new(&n.text); para.level = n.level.min(MAX_LEVEL); + let size = if n.level == 0 { title_pt } else { bullet_pt }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); para.props.indent = Some(-14.0); @@ -1878,6 +1913,33 @@ fn to_shapes(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). +fn text_sizes(children: &[Shape]) -> (f64, f64) { + let mut titles: Vec = vec![]; + let mut bullets: Vec = vec![]; + for c in children { + let Some(b) = &c.text else { continue }; + for p in &b.paragraphs { + let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; + if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } + } + } + let mode = |v: &mut Vec, d: f64| { + v.sort_by(|a, b| a.total_cmp(b)); + let mut best = (d, 0usize); + let mut k = 0; + while k < v.len() { + let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); + if j > best.1 { + best = (v[k], j); + } + k += j; + } + best.0 + }; + (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) +} + /// The graphic `gid` with its outline (typed text synced). fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { let st = s.doc().ok()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac033837..327294c4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1000,6 +1000,8 @@ fn smartart_converts_to_and_from_text_and_shapes() { let tb = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == tid).unwrap(); assert!(tb.diagram.is_none()); assert_eq!(tb.text.as_ref().unwrap().paragraphs.len(), 3); + // The text keeps the size it had in the graphic. + assert!(tb.text.as_ref().unwrap().paragraphs.iter().all(|p| p.runs.iter().all(|r| r.props.size.is_some_and(|z| z > 10.0)))); s.execute("edit.undo", &json!({})).unwrap(); s.execute("smartArt.toShapes", &json!({"id": gid})).unwrap(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d47d62b..0b8c4e6f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -539,6 +539,41 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { } } +/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected +/// text. +fn smartart_gallery(app: &mut SlideApp, ui: &mut Ui) { + use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; + ui.set_min_width(420.0); + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; + } + ui.label(egui::RichText::new(title).strong()); + egui::Grid::new(("smartart_gallery", cat)).num_columns(3).spacing([6.0, 2.0]).show(ui, |ui| { + for (i, (k, label)) in kinds.iter().enumerate() { + if ui.add_sized([136.0, 22.0], egui::Button::new(*label)).clicked() { + run(app, "smartArt.fromText", json!({"kind": k})); + ui.close(); + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); + ui.separator(); + } +} + fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -637,6 +672,8 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } }); + let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui)); }); }); }); diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs index 7692f704..9d88e465 100644 --- a/crates/ui-egui/src/smartart_tests.rs +++ b/crates/ui-egui/src/smartart_tests.rs @@ -51,3 +51,27 @@ fn text_pane_types_adds_and_demotes() { h.run_steps(4); assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]); } + +#[test] +fn home_convert_to_smartart_gallery_converts_the_text() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Convert to SmartArt").click(); + h.run_steps(3); + h.get_by_label("Basic Block List").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a SmartArt graphic"); + assert_eq!(d.kind, "blockList"); + assert_eq!(d.nodes.len(), 3); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..1f26fea1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -155,6 +155,8 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) painter.rect_filled(rect, CornerRadius::same(4), c); } icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); + // Named for screen readers (and tests) by its tooltip. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); resp.on_hover_text(tip) }