From 33252f28dbd8554b29962251a56fa903c0157e10 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:34:55 +0300 Subject: [PATCH] SmartArt: Convert to SmartArt gallery on Home, Convert to Text keeps the text size Home > Paragraph gets a Convert to SmartArt button whose menu lists every layout by category (List, Process, Cycle, Hierarchy, Relationship, Matrix, Pyramid, Picture); a click converts the selected text. Convert to Text sets the text box's titles and bullets to the sizes the graphic showed them at. Icon toggles carry their tooltip as their accessible name. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 62 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 2 + crates/ui-egui/src/ribbon.rs | 37 +++++++++++++++++ crates/ui-egui/src/smartart_tests.rs | 24 +++++++++++ crates/ui-egui/src/widgets.rs | 2 + 5 files changed, 127 insertions(+) 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) }