mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
This commit is contained in:
@@ -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<CommandSpec> {
|
||||
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<usize>,
|
||||
picture: Option<usize>,
|
||||
text: Vec<usize>,
|
||||
}
|
||||
|
||||
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<Idea> {
|
||||
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<Xfrm> = 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<Value> {
|
||||
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::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+64
-18
@@ -600,30 +600,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,
|
||||
@@ -631,9 +662,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user