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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 02:34:55 +03:00
co-authored by Cursor
parent 7764c27cc4
commit 33252f28db
5 changed files with 127 additions and 0 deletions
+62
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<f64> = vec![];
let mut bullets: Vec<f64> = 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<f64>, 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()?;
+2
View File
@@ -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();
+37
View File
@@ -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));
});
});
});
+24
View File
@@ -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);
}
+2
View File
@@ -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)
}