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
@@ -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 ),@@ -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 {@@ -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();@@ -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; new file mode 100644@@ -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");+ }+}new file mode 100644@@ -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));+}@@ -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})); }