mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #634 from NikitkaRa: SmartArt: layout gallery thumbnails and hover preview
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user