diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 66b8dda4..03276e9f 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -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 { 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) } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1d2b8f3a..94130974 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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| { diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs index 9d88e465..0e668439 100644 --- a/crates/ui-egui/src/smartart_tests.rs +++ b/crates/ui-egui/src/smartart_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 02c56925..e60aafbe 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -46,10 +46,58 @@ pub struct Textures { /// Canvas renders so far (tests, performance checks). pub canvas_renders: u64, thumbs: HashMap, + /// SmartArt gallery pictures by layout, style and colours (and, for hover previews, the + /// outline's hash). + smartart: HashMap, 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 { + 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; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ddd7a46b..63ab3934 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 =