collective

Browse / github.com/storytold/deckcraft / patch

Format Shape Effects: preset tiles for Shadow, Glow and Soft Edges, shadow Size

Format Shape Effects: preset tiles for Shadow, Glow and Soft Edges, shadow Size Effects tab (Format Shape pane): - Shadow, Glow and Soft Edges choices are now small tiles that draw each preset on a sample card (selected preset outlined, accessible names, tooltips) instead of text buttons. - Glow offers its presets (none, 5, 8, 11, 18 pt); Soft Edges offers none, 1, 2.5, 5, 10, 25 pt. - New shadow Size slider (1–200%), via `shadowProps.size` on `shape.effects` (scales both axes). Tests: engine test for `shadowProps.size` (incl. clamping); kittest drives each tile and checks the selected marker; unit test for current-preset detection. All pass; clippy -D warnings clean on

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1449, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+306 −15 in 7 files
Review
pending
Shared
2026-10-28
Id
978cf269f442827e8eef11b2

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/shape.rs crates/engine/src/cmd/shape/appearance.rs crates/engine/src/tests.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/panels/effect_presets.rs crates/ui-egui/src/panels/effect_presets_tests.rs crates/ui-egui/src/panels/format_shape.rs

diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs--- a/crates/engine/src/cmd/shape.rs+++ b/crates/engine/src/cmd/shape.rs@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Shape Effects",             ["Shape Format", "Shape Styles"],             None,-            "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",+            "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, size?: 0.01..2 (1 = 100%), blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",             has_selection,             effects         ),diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs--- a/crates/engine/src/cmd/shape/appearance.rs+++ b/crates/engine/src/cmd/shape/appearance.rs@@ -397,6 +397,12 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {             if let Some(d) = num(v, "dir") {                 x.dir = d.rem_euclid(360.0);             }+            // PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.+            if let Some(z) = num(v, "size") {+                let z = z.clamp(0.01, 2.0);+                x.sx = z;+                x.sy = z;+            }             *slot = Some(x);         }         if let Some(v) = &glow_props {diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -2320,6 +2320,15 @@ fn shape_effects_props_adjust_shadow_glow_and_reflection() {     let f = fx(&s);     assert!(f.outer_shadow.is_none());     assert_eq!(f.inner_shadow.unwrap().blur, 3.0);+    // Size scales both axes (PowerPoint's 0–200%); out-of-range values clamp.+    s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap();+    s.execute("shape.effects", &json!({"shadowProps": {"size": 1.5}})).unwrap();+    let sw = fx(&s).inner_shadow.unwrap();+    assert_eq!((sw.sx, sw.sy), (1.5, 1.5));+    s.execute("shape.effects", &json!({"shadowProps": {"size": 9}})).unwrap();+    assert_eq!(fx(&s).inner_shadow.unwrap().sx, 2.0);+    s.execute("shape.effects", &json!({"shadowProps": {"size": 0}})).unwrap();+    assert_eq!(fx(&s).inner_shadow.unwrap().sy, 0.01);     // Glow: size, colour, transparency; size 0 removes it.     s.execute("shape.effects", &json!({"glowProps": {"radius": 12, "transparency": 0.5}})).unwrap();     let g = fx(&s).glow.unwrap();diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs--- a/crates/ui-egui/src/panels.rs+++ b/crates/ui-egui/src/panels.rs@@ -14,6 +14,7 @@ mod animation; mod background; mod comments; mod designer;+mod effect_presets; mod format_shape; mod smartart; mod thesaurus;@@ -121,6 +122,9 @@ pub mod thumbs; #[cfg(test)] mod background_tests; +#[cfg(test)]+mod effect_presets_tests;+ #[cfg(test)] mod pattern_fill_tests; diff --git a/crates/ui-egui/src/panels/effect_presets.rs b/crates/ui-egui/src/panels/effect_presets.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/panels/effect_presets.rs@@ -0,0 +1,202 @@+//! Format Shape ▸ Effects: preset tiles that draw what each Shadow, Glow and Soft Edges preset does+//! to a small card, so the choice is made by looking rather than by reading names.++use deckcraft_model::style::Effects;+use egui::{Color32, CornerRadius, Painter, Rect, Role, Sense, Stroke, StrokeKind, Ui, WidgetInfo, pos2, vec2};++use crate::theme::Tokens;++const TILE: egui::Vec2 = vec2(46.0, 38.0);+const SHADOWS: [(&str, &str); 6] = [+    ("none", "No shadow"),+    ("outer", "Outer shadow"),+    ("bottom", "Bottom shadow"),+    ("center", "Centered shadow"),+    ("inner", "Inner shadow"),+    ("perspective", "Perspective shadow"),+];+/// The glow radii (pt) the ribbon's Glow menu offers.+pub(super) const GLOWS: [f64; 4] = [5.0, 8.0, 11.0, 18.0];+/// The soft-edge radii (pt) the ribbon's Soft Edges menu offers; 0 is "none".+pub(super) const SOFT_EDGES: [f64; 6] = [0.0, 1.0, 2.5, 5.0, 10.0, 25.0];++/// The `shape.effects` shadow preset id that `fx`'s current shadow came from.+pub(super) fn shadow_preset_id(fx: &Effects) -> &'static str {+    if fx.inner_shadow.is_some() {+        return "inner";+    }+    match &fx.outer_shadow {+        None => "none",+        Some(s) if s.align == "bl" => "perspective",+        Some(s) if s.dist == 0.0 => "center",+        Some(s) if s.dir == 90.0 => "bottom",+        Some(_) => "outer",+    }+}++/// A row of shadow preset tiles; returns the id of the one clicked.+pub(super) fn shadow_row(ui: &mut Ui, current: &str) -> Option<&'static str> {+    let mut picked = None;+    ui.horizontal_wrapped(|ui| {+        for (id, label) in SHADOWS {+            if tile(ui, label, id == current, |p, r| paint_shadow(p, r, id)).clicked() {+                picked = Some(id);+            }+        }+    });+    picked+}++/// A row of glow tiles (the first clears it); returns the radius clicked, 0 for none.+pub(super) fn glow_row(ui: &mut Ui, current: f64) -> Option<f64> {+    let mut picked = None;+    ui.horizontal_wrapped(|ui| {+        for r in std::iter::once(0.0).chain(GLOWS) {+            let label = if r == 0.0 { "No glow".to_string() } else { format!("{r} pt glow") };+            if tile(ui, &label, r == current, |p, rect| paint_glow(p, rect, r)).clicked() {+                picked = Some(r);+            }+        }+    });+    picked+}++/// A row of soft-edge tiles (the first clears it); returns the radius clicked, 0 for none.+pub(super) fn soft_edge_row(ui: &mut Ui, current: f64) -> Option<f64> {+    let mut picked = None;+    ui.horizontal_wrapped(|ui| {+        for r in SOFT_EDGES {+            let label = if r == 0.0 { "No soft edges".to_string() } else { format!("{r} pt soft edges") };+            if tile(ui, &label, r == current, |p, rect| paint_soft(p, rect, r)).clicked() {+                picked = Some(r);+            }+        }+    });+    picked+}++fn tile(ui: &mut Ui, label: &str, selected: bool, paint: impl FnOnce(&Painter, Rect)) -> egui::Response {+    let t = Tokens::get(ui.ctx());+    let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click());+    resp.widget_info(|| WidgetInfo::selected(Role::Button, true, selected, label));+    let p = ui.painter();+    let round = CornerRadius::same(5);+    p.rect_filled(rect, round, if resp.hovered() { t.hover } else { t.field_bg });+    paint(&p.with_clip_rect(rect.shrink(1.0)), rect);+    let stroke = if selected { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };+    p.rect_stroke(rect, round, stroke, StrokeKind::Inside);+    resp.on_hover_text(label)+}++/// The sample card every preview draws on, centred in `tile`.+fn card(tile: Rect) -> Rect {+    Rect::from_center_size(tile.center(), vec2(22.0, 16.0))+}++fn card_fill(p: &Painter, r: Rect) {+    p.rect_filled(r, CornerRadius::same(2), Tokens::get(p.ctx()).accent);+}++/// `layers` rounded rects growing from `r` by up to `spread`, each faint, so the edge falls off.+fn halo(p: &Painter, r: Rect, spread: f32, color: Color32, layers: u32) {+    for i in (1..=layers).rev() {+        let k = i as f32 / layers as f32;+        let a = (f32::from(color.a()) * (1.0 - k * 0.8) / layers as f32 * 2.0).min(255.0) as u8;+        p.rect_filled(+            r.expand(spread * k),+            CornerRadius::same((2.0 + spread * k) as u8),+            Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a),+        );+    }+}++fn paint_shadow(p: &Painter, tile: Rect, id: &str) {+    let r = card(tile);+    let ink = Color32::from_rgba_unmultiplied(0, 0, 0, 120);+    match id {+        "outer" => halo(p, r.translate(vec2(3.0, 3.0)), 3.0, ink, 4),+        "bottom" => halo(p, r.translate(vec2(0.0, 4.0)), 3.0, ink, 4),+        "center" => halo(p, r, 5.0, ink, 5),+        "perspective" => {+            let base = r.max.y + 1.0;+            let slant = [pos2(r.min.x - 6.0, base), pos2(r.max.x - 8.0, base), pos2(r.max.x - 2.0, base + 5.0), pos2(r.min.x - 12.0, base + 5.0)];+            p.add(egui::Shape::convex_polygon(slant.to_vec(), Color32::from_rgba_unmultiplied(0, 0, 0, 90), Stroke::NONE));+        }+        _ => {}+    }+    card_fill(p, r);+    if id == "inner" {+        for i in 0..4 {+            let a = 110 - i * 25;+            p.rect_stroke(+                r.shrink(i as f32),+                CornerRadius::same(2),+                Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, a as u8)),+                StrokeKind::Inside,+            );+        }+    }+    if id == "none" {+        p.line_segment([tile.left_bottom() + vec2(6.0, -6.0), tile.right_top() + vec2(-6.0, 6.0)], Stroke::new(1.5, Tokens::get(p.ctx()).text_faint));+    }+}++fn paint_glow(p: &Painter, tile: Rect, radius: f64) {+    let r = card(tile);+    if radius > 0.0 {+        // 18 pt fills the tile's margin; smaller radii scale down.+        halo(p, r, (radius as f32 / 18.0 * 9.0).max(2.0), Tokens::get(p.ctx()).accent.gamma_multiply(0.9), 6);+    }+    card_fill(p, r);+    if radius == 0.0 {+        p.line_segment([tile.left_bottom() + vec2(6.0, -6.0), tile.right_top() + vec2(-6.0, 6.0)], Stroke::new(1.5, Tokens::get(p.ctx()).text_faint));+    }+}++fn paint_soft(p: &Painter, tile: Rect, radius: f64) {+    let t = Tokens::get(p.ctx());+    let r = card(tile).expand(2.0);+    card_fill(p, r);+    // Rings of the tile's own background, opaque at the edge and clear inward, feather the rim.+    let depth = (radius as f32 / 25.0 * 7.0).round().max(if radius > 0.0 { 1.0 } else { 0.0 }) as u32;+    let bg = t.field_bg;+    for i in 0..depth {+        let a = (255.0 * (1.0 - i as f32 / depth as f32)) as u8;+        let c = Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), a);+        p.rect_stroke(r.shrink(i as f32), CornerRadius::same(2), Stroke::new(1.0, c), StrokeKind::Inside);+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use deckcraft_model::style::Shadow;+    use deckcraft_model::{ColorRef, SchemeSlot};++    fn shadow(dist: f64, dir: f64, align: &str, inner: bool) -> Shadow {+        Shadow {+            color: ColorRef::scheme(SchemeSlot::Dk1),+            blur: 4.0,+            dist,+            dir,+            inner,+            sx: 1.0,+            sy: 1.0,+            kx: 0.0,+            ky: 0.0,+            align: align.into(),+            rotate_with_shape: false,+        }+    }++    #[test]+    fn shadow_preset_id_names_each_preset() {+        let with = |o: Option<Shadow>, i: Option<Shadow>| Effects { outer_shadow: o, inner_shadow: i, ..Effects::default() };+        assert_eq!(shadow_preset_id(&with(None, None)), "none");+        assert_eq!(shadow_preset_id(&with(Some(shadow(3.0, 45.0, "tl", false)), None)), "outer");+        assert_eq!(shadow_preset_id(&with(Some(shadow(3.0, 90.0, "tl", false)), None)), "bottom");+        assert_eq!(shadow_preset_id(&with(Some(shadow(0.0, 45.0, "tl", false)), None)), "center");+        assert_eq!(shadow_preset_id(&with(Some(shadow(0.0, 90.0, "bl", false)), None)), "perspective");+        assert_eq!(shadow_preset_id(&with(None, Some(shadow(3.0, 45.0, "tl", true)))), "inner");+    }+}diff --git a/crates/ui-egui/src/panels/effect_presets_tests.rs b/crates/ui-egui/src/panels/effect_presets_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/panels/effect_presets_tests.rs@@ -0,0 +1,73 @@+//! Format Shape ▸ Effects: the Shadow, Glow and Soft Edges preset tiles.++use deckcraft_engine::Session;+use deckcraft_model::style::Effects;+use egui::accesskit::Toggled;+use egui_kittest::Harness;+use egui_kittest::kittest::{NodeT, Queryable};+use serde_json::json;++use crate::{Services, SlideApp};++fn harness() -> Harness<'static, SlideApp> {+    let mut s = Session::with_new();+    let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 120.0]})).unwrap();+    let id = r.get("id").and_then(|v| v.as_u64()).unwrap();+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();+    let mut app = SlideApp::new(s, Services::default());+    app.ui.pane = Some("format".into());+    app.ui.format_tab = "effects".into();+    let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state(+        |ui, app: &mut SlideApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(3);+    h+}++fn effects(h: &Harness<'_, SlideApp>) -> Effects {+    h.state().session.active().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default()+}++fn click(h: &mut Harness<'_, SlideApp>, label: &str) {+    h.get_by_label(label).click();+    h.run_steps(3);+}++#[test]+fn preset_tiles_set_shadow_glow_and_soft_edges() {+    let mut h = harness();+    click(&mut h, "Bottom shadow");+    let sw = effects(&h).outer_shadow.unwrap();+    assert_eq!((sw.dir, sw.dist), (90.0, 3.0));+    // The Size slider appears once there is a shadow.+    assert!(h.query_all_by_label("Size").count() >= 2);++    click(&mut h, "Inner shadow");+    let fx = effects(&h);+    assert!(fx.outer_shadow.is_none() && fx.inner_shadow.is_some());+    click(&mut h, "No shadow");+    let fx = effects(&h);+    assert!(fx.outer_shadow.is_none() && fx.inner_shadow.is_none());++    click(&mut h, "18 pt glow");+    assert_eq!(effects(&h).glow.unwrap().radius, 18.0);+    click(&mut h, "No glow");+    assert!(effects(&h).glow.is_none());++    click(&mut h, "10 pt soft edges");+    assert_eq!(effects(&h).soft_edge, Some(10.0));+    click(&mut h, "No soft edges");+    assert_eq!(effects(&h).soft_edge, None);+}++#[test]+fn the_current_preset_tile_is_marked_selected() {+    let mut h = harness();+    click(&mut h, "5 pt glow");+    assert_eq!(h.get_by_label("5 pt glow").accesskit_node().toggled(), Some(Toggled::True));+    assert_eq!(h.get_by_label("11 pt glow").accesskit_node().toggled(), Some(Toggled::False));+}diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs--- a/crates/ui-egui/src/panels/format_shape.rs+++ b/crates/ui-egui/src/panels/format_shape.rs@@ -1,3 +1,4 @@+use super::effect_presets; use super::*;  /// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s@@ -56,21 +57,10 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {     match app.ui.format_tab.as_str() {         "effects" => {             ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0)));-            ui.horizontal_wrapped(|ui| {-                for (l, v) in [-                    ("None", "none"),-                    ("Outer", "outer"),-                    ("Bottom", "bottom"),-                    ("Center", "center"),-                    ("Inner", "inner"),-                    ("Perspective", "perspective"),-                ] {-                    if ui.button(l).clicked() {-                        run(app, "shape.effects", json!({"shadow": v}));-                    }-                }-            });             let fx = sh.effects.clone().unwrap_or_default();+            if let Some(v) = effect_presets::shadow_row(ui, effect_presets::shadow_preset_id(&fx)) {+                run(app, "shape.effects", json!({"shadow": v}));+            }             let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref());             if let Some(sw) = shadow {                 color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}})));@@ -86,6 +76,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {                     "transparency",                     0.01,                 );+                fx_slider(app, ui, ("shadow", sh.id.0, "s"), "Size", sw.sx * 100.0, 1.0..=200.0, "%", "shadowProps", "size", 0.01);                 fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0);                 fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0);                 fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0);@@ -119,6 +110,9 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {             ui.add_space(6.0);             ui.label(egui::RichText::new("Glow").font(theme::bold(13.0)));             let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius);+            if let Some(r) = effect_presets::glow_row(ui, radius) {+                run(app, "shape.effects", json!({"glow": if r > 0.0 { json!(r) } else { Value::Null }}));+            }             fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0);             if let Some(g) = &fx.glow {                 color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}})));@@ -137,6 +131,9 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {             }             ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0)));             let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0);+            if let Some(r) = effect_presets::soft_edge_row(ui, s) {+                run(app, "shape.effects", json!({"softEdges": r}));+            }             if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() {                 run(app, "shape.effects", json!({"softEdges": s}));             }