From ede2ca69b630d3be9ebdfac6301c8ec933bd6923 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:10:59 +0300 Subject: [PATCH] Designer: layout ideas that rearrange the current slide (#314) Design Ideas showed the current slide once per built-in theme, and picking one ran design.theme on the whole presentation; with a large picture every idea looked the same. The pane also re-rendered eight slides every frame. - New commands design.ideas (query) and design.applyIdea {id, slide?} (cmd/ideas.rs). A slide with a picture gets full-bleed with the title on a band, picture left or right with the text beside it, and a framed picture; a text slide gets a title column, a centred title and a title at the foot. An idea moves the slide's own shapes, crops the picture to cover its new box, sends it to the back, and changes only that slide (one undo step). - The pane shows the layout ideas first, then the themes under a "whole presentation" heading, and caches the thumbnails until the deck revision, slide or pane width changes. Co-authored-by: Cursor --- crates/engine/src/cmd/ideas.rs | 325 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/panes.rs | 82 +++++++-- 3 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/cmd/ideas.rs diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs new file mode 100644 index 00000000..45676754 --- /dev/null +++ b/crates/engine/src/cmd/ideas.rs @@ -0,0 +1,325 @@ +//! Design Ideas: arrangements of what is on the current slide (picture full-bleed with the title +//! on a band, picture on one half, a framed picture, text-only layouts). An idea moves and +//! resizes the slide's own shapes and crops pictures to their new box; it changes only that +//! slide. Everything is computed here, offline. + +use deckcraft_geom::Xfrm; +use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Selection, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(query "design.ideas", "Design Ideas", [], None, "{slide?} → [{id, label}] ideas for the slide", has_slide, ideas_cmd), + cmd!("design.applyIdea", "Apply Design Idea", ["Home", "Designer"], None, "{id, slide?}", has_slide, apply_cmd), + ] +} + +/// An idea: id and label. +pub type Idea = (&'static str, &'static str); + +const PICTURE_IDEAS: [Idea; 4] = [ + ("fullBleed", "Full-bleed picture, title on a band"), + ("pictureLeft", "Picture left, text right"), + ("pictureRight", "Picture right, text left"), + ("framed", "Framed picture beside the title"), +]; +const TEXT_IDEAS: [Idea; 3] = + [("titleColumn", "Title column beside the text"), ("centred", "Centred title over the text"), ("titleBottom", "Text above, title at the foot")]; + +/// The roles of a slide's top-level shapes, by index. +struct Roles { + title: Option, + picture: Option, + text: Vec, +} + +fn roles(shapes: &[Shape]) -> Roles { + let mut r = Roles { title: None, picture: None, text: vec![] }; + for (i, s) in shapes.iter().enumerate() { + if s.hidden { + continue; + } + if s.ph.as_ref().is_some_and(|p| p.kind.is_title()) && r.title.is_none() { + r.title = Some(i); + } else if matches!(s.kind, ShapeKind::Picture { .. }) { + // The largest picture leads. + let area = |j: usize| shapes.get(j).and_then(|s| s.xfrm).map(|x| x.w * x.h).unwrap_or(0.0); + if r.picture.is_none_or(|p| area(i) > area(p)) { + r.picture = Some(i); + } + } else if s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()) { + r.text.push(i); + } + } + r +} + +/// The ideas for slide `index` (none when it has nothing to arrange). +pub fn ideas_for(doc: &Presentation, index: usize) -> Vec { + let Some(slide) = doc.slides.get(index) else { return vec![] }; + let r = roles(&slide.shapes); + if r.picture.is_some() { + PICTURE_IDEAS.to_vec() + } else if r.title.is_some() && !r.text.is_empty() { + TEXT_IDEAS.to_vec() + } else { + vec![] + } +} + +/// Crop a picture whose source has aspect `src` (w/h) to fill a box of aspect `dst`, centred. +fn cover_crop(src: f64, dst: f64) -> [f64; 4] { + if !(src.is_finite() && dst.is_finite() && src > 0.0 && dst > 0.0) { + return [0.0; 4]; + } + if src > dst { + let cut = (1.0 - dst / src) / 2.0; + [cut, 0.0, cut, 0.0] + } else { + let cut = (1.0 - src / dst) / 2.0; + [0.0, cut, 0.0, cut] + } +} + +/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). +pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> { + if !ideas_for(doc, index).iter().any(|i| i.0 == id) { + return Err(bad("design.applyIdea", format!("no idea `{id}` for this slide (see design.ideas)"))); + } + let (sw, sh) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0)); + let sel = Selection { slide: index, ..sel.clone() }; + let shapes = doc.slides.get(index).map(|s| s.shapes.clone()).ok_or_else(|| bad("design.applyIdea", "no slide"))?; + let r = roles(&shapes); + let boxes: Vec = shapes.iter().map(|s| xfrm_of(doc, &sel, s)).collect(); + let m = sw.min(sh) * 0.06; + let mut place: Vec<(usize, Xfrm)> = vec![]; + // Text shapes stacked in a box. + let stack = |b: Xfrm, out: &mut Vec<(usize, Xfrm)>| { + let n = r.text.len().max(1) as f64; + let gap = m * 0.4; + let h = ((b.h - gap * (n - 1.0)) / n).max(1.0); + for (k, i) in r.text.iter().enumerate() { + out.push((*i, Xfrm::new(b.x, b.y + k as f64 * (h + gap), b.w, h))); + } + }; + let mut band = false; + match id { + "fullBleed" => { + if let Some(p) = r.picture { + place.push((p, Xfrm::new(0.0, 0.0, sw, sh))); + } + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, sh * 0.68, sw - 2.0 * m, sh * 0.2))); + band = true; + } + stack(Xfrm::new(m, sh * 0.06, sw * 0.45, sh * 0.5), &mut place); + } + "pictureLeft" | "pictureRight" => { + let left = id == "pictureLeft"; + let half = sw * 0.5; + let (px, tx) = if left { (0.0, half + m) } else { (half, m) }; + if let Some(p) = r.picture { + place.push((p, Xfrm::new(px, 0.0, half, sh))); + } + let tw = half - 2.0 * m; + if let Some(t) = r.title { + place.push((t, Xfrm::new(tx, m, tw, sh * 0.24))); + } + stack(Xfrm::new(tx, m + sh * 0.28, tw, sh - 2.0 * m - sh * 0.28), &mut place); + } + "framed" => { + if let Some(p) = r.picture { + place.push((p, Xfrm::new(sw * 0.42, m * 1.5, sw * 0.58 - m * 1.5, sh - 3.0 * m))); + } + let tw = sw * 0.42 - 2.0 * m; + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, m * 1.5, tw, sh * 0.3))); + } + stack(Xfrm::new(m, m * 1.5 + sh * 0.34, tw, sh - 3.0 * m - sh * 0.34), &mut place); + } + "titleColumn" => { + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, m, sw * 0.32, sh - 2.0 * m))); + } + stack(Xfrm::new(sw * 0.36, m, sw * 0.64 - m, sh - 2.0 * m), &mut place); + } + "centred" => { + if let Some(t) = r.title { + place.push((t, Xfrm::new(sw * 0.1, m, sw * 0.8, sh * 0.26))); + } + stack(Xfrm::new(sw * 0.18, m + sh * 0.3, sw * 0.64, sh - 2.0 * m - sh * 0.3), &mut place); + } + "titleBottom" => { + stack(Xfrm::new(m, m, sw - 2.0 * m, sh * 0.62), &mut place); + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, sh * 0.72, sw - 2.0 * m, sh * 0.2))); + } + } + _ => return Err(bad("design.applyIdea", format!("no idea `{id}`"))), + } + let slide = doc.slides.get_mut(index).ok_or_else(|| bad("design.applyIdea", "no slide"))?; + let slide = std::sync::Arc::make_mut(slide); + for (i, x) in place { + let (Some(s), Some(old)) = (slide.shapes.get_mut(i), boxes.get(i)) else { continue }; + if let ShapeKind::Picture { fill } = &mut s.kind { + // The source's aspect, from the current box and crop. + let c = fill.crop; + let (fw, fh) = ((1.0 - c[0] - c[2]).max(0.01), (1.0 - c[1] - c[3]).max(0.01)); + let src = (old.w / fw) / (old.h / fh).max(1e-6); + fill.crop = cover_crop(src, x.w / x.h.max(1e-6)); + } + s.xfrm = Some(Xfrm { rot: 0.0, ..x }); + } + // The picture goes to the back, so text over it stays readable. + if let Some(p) = r.picture + && p < slide.shapes.len() + { + let pic = slide.shapes.remove(p); + slide.shapes.insert(0, pic); + } + if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { + t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))); + } + Ok(()) +} + +fn ideas_cmd(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>())) +} + +fn apply_cmd(s: &mut Session, p: &Value) -> Result { + let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string(); + let slide = usize_param(p, "slide"); + s.edit(|doc, sel| { + let i = slide.unwrap_or(sel.slide); + apply_idea(doc, sel, i, &id)?; + Ok(json!({"slide": i, "id": id})) + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use std::sync::Arc; + + use deckcraft_model::style::PictureFill; + use deckcraft_model::{PhType, Placeholder, ShapeId}; + + use super::*; + + fn deck(picture: bool) -> Session { + let mut s = Session::with_new(); + let st = s.doc_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.clear(); + let title = Shape { + id: ShapeId(500), + ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }), + xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)), + text: Some(deckcraft_model::text::TextBody::from_text("Harbour at dawn")), + ..Default::default() + }; + let body = Shape { + id: ShapeId(501), + xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)), + text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")), + ..Default::default() + }; + slide.shapes.push(title); + slide.shapes.push(body); + if picture { + // A 2:1 picture covering most of the slide, over the title. + let pic = Shape { + id: ShapeId(502), + kind: ShapeKind::Picture { fill: PictureFill::default() }, + xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 450.0)), + ..Default::default() + }; + slide.shapes.push(pic); + } + s + } + + fn shape(s: &Session, id: u32) -> Shape { + s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.id == ShapeId(id)).cloned().unwrap() + } + + fn overlaps(a: Xfrm, b: Xfrm) -> bool { + a.x < b.x + b.w - 0.5 && b.x < a.x + a.w - 0.5 && a.y < b.y + b.h - 0.5 && b.y < a.y + a.h - 0.5 + } + + #[test] + fn picture_slides_get_layout_ideas_and_text_slides_get_text_ideas() { + let s = deck(true); + let ids: Vec<&str> = ideas_for(&s.doc().unwrap().doc, 0).iter().map(|i| i.0).collect(); + assert_eq!(ids, ["fullBleed", "pictureLeft", "pictureRight", "framed"]); + let s = deck(false); + assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3); + assert!(ideas_for(&s.doc().unwrap().doc, 9).is_empty()); + } + + #[test] + fn picture_left_puts_text_beside_the_picture_cropped_to_fill() { + let mut s = deck(true); + let theme_before = s.doc().unwrap().doc.masters[0].theme.name.clone(); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + let (pic, title, body) = (shape(&s, 502), shape(&s, 500), shape(&s, 501)); + let px = pic.xfrm.unwrap(); + assert_eq!((px.x, px.y, px.w, px.h), (0.0, 0.0, 480.0, 540.0)); + assert!(!overlaps(px, title.xfrm.unwrap()) && !overlaps(px, body.xfrm.unwrap())); + assert!(!overlaps(title.xfrm.unwrap(), body.xfrm.unwrap())); + // 2:1 source into an 8:9 box: cut equally from left and right. + let ShapeKind::Picture { fill } = &pic.kind else { panic!() }; + assert!(fill.crop[0] > 0.25 && (fill.crop[0] - fill.crop[2]).abs() < 1e-9 && fill.crop[1] == 0.0, "{:?}", fill.crop); + // Only this slide's arrangement changed, not the theme; undo restores it. + assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, theme_before); + assert_eq!(s.doc().unwrap().doc.slides[0].shapes[0].id, ShapeId(502), "picture at the back"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shape(&s, 502).xfrm.unwrap().w, 900.0); + } + + #[test] + fn full_bleed_covers_the_slide_and_puts_the_title_on_a_band() { + let mut s = deck(true); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + let px = shape(&s, 502).xfrm.unwrap(); + assert_eq!((px.w, px.h), (960.0, 540.0)); + let t = shape(&s, 500); + assert!(t.fill.is_some()); + assert!(t.xfrm.unwrap().y > 300.0); + } + + #[test] + fn every_idea_keeps_shapes_on_the_slide_and_unknown_ideas_are_refused() { + for pic in [true, false] { + let probe = deck(pic); + for (id, _) in ideas_for(&probe.doc().unwrap().doc, 0) { + let mut s = deck(pic); + s.execute("design.applyIdea", &json!({"id": id})).unwrap(); + for sh in &s.doc().unwrap().doc.slides[0].shapes { + let x = sh.xfrm.unwrap(); + assert!(x.x >= -0.01 && x.y >= -0.01 && x.x + x.w <= 960.01 && x.y + x.h <= 540.01 && x.w > 0.0 && x.h > 0.0, "{id}: {x:?}"); + } + } + } + let mut s = deck(false); + assert!(s.execute("design.applyIdea", &json!({"id": "fullBleed"})).is_err()); + assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err()); + } + + #[test] + fn cover_crop_matches_the_box_aspect() { + let c = cover_crop(2.0, 1.0); + assert!((c[0] - 0.25).abs() < 1e-12 && c[1] == 0.0); + let c = cover_crop(1.0, 2.0); + assert!((c[1] - 0.25).abs() < 1e-12 && c[0] == 0.0); + assert_eq!(cover_crop(f64::NAN, 1.0), [0.0; 4]); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..280b163d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod design; pub mod edit; pub mod file; pub mod format; +pub mod ideas; pub mod insert; pub mod inspect; pub mod media; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(text::specs()); v.extend(format::specs()); v.extend(design::specs()); + v.extend(ideas::specs()); v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..3570482d 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -598,30 +598,61 @@ fn comments(app: &mut SlideApp, ui: &mut Ui) { } /// Design Ideas: offline suggestions — the current slide in each theme and layout variations. +/// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight +/// slides every frame made the pane cost ~25 ms a frame). +#[derive(Clone, Default)] +struct IdeaThumbs { + key: (u64, u64, usize, u32), + tex: Vec<(String, egui::TextureHandle, u32, u32)>, +} + fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a look for your slides. Ideas are generated on your device.").color(t.text_dim)); + ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let idx = st.selection.slide; + let sel = st.selection.clone(); + let key_doc = (st.uid, st.revision); let w = ui.available_width() - 8.0; - for th in deckcraft_model::theme::builtin_themes() { - let mut d = (*doc).clone(); - if let Some(m) = d.masters.first_mut() { - let m = std::sync::Arc::make_mut(m); - m.theme = th.clone(); + let ppp = ui.ctx().pixels_per_point(); + let px = (w * ppp).max(16.0) as u32; + let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); + let themes = deckcraft_model::theme::builtin_themes(); + let cache_id = egui::Id::new("design-idea-thumbs"); + let key = (key_doc.0, key_doc.1, idx, px); + let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default(); + if cache.key != key || cache.tex.is_empty() { + cache = IdeaThumbs { key, tex: vec![] }; + let mut render = |name: String, d: &deckcraft_model::Presentation| { + let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let img = deckcraft_render::render_slide( + d, + idx, + &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, + ); + let tex = ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + cache.tex.push((name, tex, px, h)); + }; + for (id, _) in &ideas { + let mut d = (*doc).clone(); + if deckcraft_engine::cmd::ideas::apply_idea(&mut d, &sel, idx, id).is_ok() { + render(format!("layout:{id}"), &d); + } } - let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; - let img = deckcraft_render::render_slide( - &d, - idx, - &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, - ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); + for th in &themes { + let mut d = (*doc).clone(); + if let Some(m) = d.masters.first_mut() { + std::sync::Arc::make_mut(m).theme = th.clone(); + } + render(format!("theme:{}", th.name), &d); + } + ui.ctx().data_mut(|d| d.insert_temp(cache_id, cache.clone())); + } + let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool { + let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false }; + let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click()); ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( r, @@ -629,9 +660,24 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - if resp.on_hover_text(&th.name).clicked() { + let clicked = resp.on_hover_text(tip).clicked(); + ui.add_space(8.0); + clicked + }; + if ideas.is_empty() { + ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); + } + for (id, label) in &ideas { + if thumb(ui, &format!("layout:{id}"), label) { + run(app, "design.applyIdea", json!({"id": id})); + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); + ui.add_space(4.0); + for th in &themes { + if thumb(ui, &format!("theme:{}", th.name), &th.name) { run(app, "design.theme", json!({"name": th.name})); } - ui.add_space(8.0); } }