Merge PR #634 from NikitkaRa: SmartArt: layout gallery thumbnails and hover preview

This commit is contained in:
Brandon Thomas
2026-10-14 13:29:01 -04:00
5 changed files with 188 additions and 36 deletions
+28
View File
@@ -126,6 +126,34 @@ pub fn category(kind: &str) -> &'static str {
}
}
/// A layout's gallery picture: the graphic drawn with sample nodes (or `nodes`) in a 320 × 200 pt
/// group at the origin, in the style and colours given, with no text in the samples.
pub fn preview(kind: &str, style: &str, colors: &str, nodes: Option<&[DiagramNode]>) -> Shape {
let sample = |levels: &[u8]| -> Vec<DiagramNode> { levels.iter().map(|l| DiagramNode { level: *l, ..Default::default() }).collect() };
let nodes = match nodes {
Some(n) if !n.is_empty() => n.to_vec(),
_ => match category(kind) {
"hierarchy" => sample(&[0, 1, 2, 2, 1]),
"matrix" => sample(&[0, 0, 0, 0, 0]),
"list" if kind != "blockList" && kind != "tableList" => sample(&[0, 1, 0, 1, 0, 1]),
_ => sample(&[0, 0, 0, 0]),
},
};
let d = Diagram { kind: kind.into(), nodes, style: style.into(), colors: colors.into(), ..Default::default() };
let area = Xfrm::new(0.0, 0.0, 320.0, 200.0);
let mut children = layout(&d, area);
for (k, c) in children.iter_mut().enumerate() {
c.id = ShapeId(10_000 + k as u32);
}
Shape {
id: ShapeId(9_999),
name: "SmartArt preview".into(),
xfrm: Some(area),
kind: ShapeKind::Group { children, child: area },
..Default::default()
}
}
pub fn is_kind(k: &str) -> bool {
LAYOUTS.iter().any(|(id, _)| *id == k)
}
+74 -35
View File
@@ -553,37 +553,79 @@ 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) {
/// A layout gallery grouped by category, each layout as a small cached picture with its name.
/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn
/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the
/// document.
fn smartart_gallery(
app: &mut SlideApp,
ui: &mut Ui,
cmd: &str,
extra: Value,
current: &str,
style: &str,
colors: &str,
nodes: Option<&[deckcraft_model::DiagramNode]>,
) {
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.set_min_width(440.0);
let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| {
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.separator();
}
ui.label(egui::RichText::new(title).strong());
egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| {
for (i, (k, label)) in kinds.iter().enumerate() {
let ctx = ui.ctx().clone();
let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192));
let r = ui
.vertical_centered(|ui| {
ui.set_width(100.0);
let r = match &thumb {
Some(t) => {
ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current))
}
None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)),
};
ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label));
r.on_hover_text(*label)
})
.inner;
if r.hovered() {
let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480));
if let Some(t) = preview {
r.show_tooltip_ui(|ui| {
ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0)));
});
}
}
if r.clicked() {
let mut args = extra.clone();
args["kind"] = json!(k);
run(app, cmd, args);
ui.close();
}
if i % 4 == 3 {
ui.end_row();
}
}
});
ui.separator();
}
});
}
fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
@@ -697,7 +739,7 @@ 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));
egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None));
});
});
});
@@ -2666,12 +2708,9 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true);
let outline = sel.as_ref().map(|(_, d)| d.nodes.clone());
egui::Popup::menu(&r).show(|ui| {
for (k, label) in deckcraft_engine::cmd::smartart::LAYOUTS {
if ui.selectable_label(kind == k, label).clicked() {
run(app, "smartArt.layout", json!({"id": gid, "kind": k}));
}
}
smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref());
});
let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
+35 -1
View File
@@ -68,10 +68,44 @@ fn home_convert_to_smartart_gallery_converts_the_text() {
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.get_by_role_and_label(egui::accesskit::Role::Button, "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);
}
#[test]
fn layout_gallery_shows_cached_pictures_and_previews_on_hover() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.smartArt", &json!({"kind": "process", "items": ["Plan", "Build", "Ship"]})).unwrap();
let mut app = SlideApp::new(s, Services::default());
app.ui.tab = "SmartArt Design".into();
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("Layouts").click();
h.run_steps(3);
let first = h.state().textures.smartart_cached();
assert!(first >= deckcraft_engine::cmd::smartart::LAYOUTS.len().min(8), "gallery pictures were drawn: {first}");
h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").hover();
h.run_steps(3);
if let Ok(path) = std::env::var("SMARTART_GALLERY_PNG") {
h.render().expect("render").save(path).unwrap();
}
let hovered = h.state().textures.smartart_cached();
assert!(hovered > first, "hovering drew a preview of the graphic's own outline");
h.run_steps(3);
assert_eq!(h.state().textures.smartart_cached(), hovered, "pictures are cached, not redrawn every frame");
h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert_eq!(deckcraft_engine::cmd::smartart::selected(st).unwrap().1.kind, "verticalBoxList");
}
+48
View File
@@ -46,10 +46,58 @@ pub struct Textures {
/// Canvas renders so far (tests, performance checks).
pub canvas_renders: u64,
thumbs: HashMap<usize, Entry>,
/// SmartArt gallery pictures by layout, style and colours (and, for hover previews, the
/// outline's hash).
smartart: HashMap<String, TextureHandle>,
clock: u64,
pub last_render_ms: f64,
}
impl Textures {
/// SmartArt gallery pictures cached so far (tests).
pub fn smartart_cached(&self) -> usize {
self.smartart.len()
}
/// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected
/// graphic's own outline), cached; drawn with the active presentation's theme.
pub fn smartart(
&mut self,
ctx: &egui::Context,
p: &deckcraft_model::Presentation,
slide: usize,
kind: &str,
style: &str,
colors: &str,
nodes: Option<&[deckcraft_model::DiagramNode]>,
width_px: u32,
) -> Option<TextureHandle> {
let mut key = format!("{kind}|{style}|{colors}|{width_px}");
if let Some(n) = nodes {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for x in n {
(x.text.as_str(), x.level).hash(&mut h);
}
key.push_str(&format!("|{:x}", h.finish()));
}
if let Some(t) = self.smartart.get(&key) {
return Some(t.clone());
}
let mut s = p.slides.get(slide).or_else(|| p.slides.first())?.as_ref().clone();
let g = deckcraft_engine::cmd::smartart::preview(kind, style, colors, nodes);
let id = g.id;
s.shapes = vec![g];
let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?;
let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR);
if self.smartart.len() > 300 {
self.smartart.clear();
}
self.smartart.insert(key, tex.clone());
Some(tex)
}
}
pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
let w = img.width as usize;
let h = img.height as usize;
+3
View File
@@ -117,6 +117,9 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
let arrow_w = if split { 12.0 } else { 0.0 };
let w = 24.0 + tw + arrow_w;
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() });
if !label.is_empty() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
}
let painter = ui.painter_at(rect);
let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y));
let main_resp =