From 14bc6a7ef933a0c980aa797caabc4ade901be91e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:52:56 +0300 Subject: [PATCH] Format Shape: shadow, glow and reflection detail controls shape.effects gains shadowProps / glowProps / reflectionProps that adjust the current effect (or add a default one): shadow colour, transparency, blur, angle and distance; glow colour, transparency and size; reflection transparency, size, distance and blur. Values are clamped, non-finite ones ignored. The Effects page of the Format Shape pane exposes them as sliders and colour pickers. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 71 ++++++++++++++++++++++++++- crates/engine/src/tests.rs | 34 +++++++++++++ crates/ui-egui/src/panes.rs | 89 ++++++++++++++++++++++++++++++++-- docs/ui-parity.md | 2 +- 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..deebeabe 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "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|…, reset?, ids?}", + "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, 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)", has_selection, effects ), @@ -450,6 +450,9 @@ fn effects(s: &mut Session, p: &Value) -> Result { _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), }); let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); + let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned(); + let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned(); + let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned(); edit_shapes(s, p, "shape.effects", |sh| { if reset { sh.effects = None; @@ -484,10 +487,76 @@ fn effects(s: &mut Session, p: &Value) -> Result { if let Some(b) = &bevel { e.bevel.clone_from(b); } + if let Some(v) = &shadow_props { + let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow }; + let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?; + if let Some(c) = v.get("color").and_then(color_value) { + let a = x.color.alpha(); + x.color = with_alpha(c, a); + } + if let Some(t) = num(v, "transparency") { + x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0)); + } + if let Some(b) = num(v, "blur") { + x.blur = b.clamp(0.0, 100.0); + } + if let Some(d) = num(v, "dist") { + x.dist = d.clamp(0.0, 200.0); + } + if let Some(d) = num(v, "dir") { + x.dir = d.rem_euclid(360.0); + } + *slot = Some(x); + } + if let Some(v) = &glow_props { + let mut g = + e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 }); + if let Some(c) = v.get("color").and_then(color_value) { + let a = g.color.alpha(); + g.color = with_alpha(c, a); + } + if let Some(t) = num(v, "transparency") { + g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0)); + } + if let Some(r) = num(v, "radius") { + g.radius = r.clamp(0.0, 150.0); + } + e.glow = (g.radius > 0.0).then_some(g); + } + if let Some(v) = &refl_props { + let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }); + if let Some(t) = num(v, "transparency") { + r.start_alpha = 1.0 - t.clamp(0.0, 1.0); + } + if let Some(sz) = num(v, "size") { + r.end_pos = sz.clamp(0.0, 1.0); + } + if let Some(d) = num(v, "dist") { + r.dist = d.clamp(0.0, 100.0); + } + if let Some(b) = num(v, "blur") { + r.blur = b.clamp(0.0, 100.0); + } + e.reflection = Some(r); + } Ok(()) }) } +/// A finite number at `key` of an effect's properties. +fn num(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) +} + +/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform. +fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if a < 1.0 { + c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32)); + } + c +} + /// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents. pub fn quick_style_for(index: usize) -> (ShapeStyle, Option, Option) { let slots = [ diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..df1d37a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,37 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn shape_effects_props_adjust_shadow_glow_and_reflection() { + // The Format Shape pane's Effects page: shadow, glow and reflection details. + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + // No shadow yet: the props add an outer one and set it. + s.execute("shape.effects", &json!({"shadowProps": {"blur": 10, "dist": 6, "dir": 405, "transparency": 0.25, "color": "accent2"}})).unwrap(); + let sw = fx(&s).outer_shadow.unwrap(); + assert_eq!((sw.blur, sw.dist, sw.dir), (10.0, 6.0, 45.0)); + assert!((sw.color.alpha() - 0.75).abs() < 1e-6); + // A new colour keeps the transparency; an inner shadow is adjusted in place. + s.execute("shape.effects", &json!({"shadowProps": {"color": "accent3"}})).unwrap(); + assert!((fx(&s).outer_shadow.unwrap().color.alpha() - 0.75).abs() < 1e-6); + s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap(); + s.execute("shape.effects", &json!({"shadowProps": {"blur": 3}})).unwrap(); + let f = fx(&s); + assert!(f.outer_shadow.is_none()); + assert_eq!(f.inner_shadow.unwrap().blur, 3.0); + // 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(); + assert_eq!(g.radius, 12.0); + assert!((g.color.alpha() - 0.5).abs() < 1e-6); + s.execute("shape.effects", &json!({"glowProps": {"radius": 0}})).unwrap(); + assert!(fx(&s).glow.is_none()); + // Reflection: transparency, size, distance, blur; hostile values are clamped or ignored. + s.execute("shape.effects", &json!({"reflectionProps": {"transparency": 0.3, "size": 2.0, "dist": 5, "blur": "x"}})).unwrap(); + let r = fx(&s).reflection.unwrap(); + assert!((r.start_alpha - 0.7).abs() < 1e-9); + assert_eq!((r.end_pos, r.dist, r.blur), (1.0, 5.0, 0.5)); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..0676472c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -130,6 +130,26 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } }); + let fx = sh.effects.clone().unwrap_or_default(); + 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)}}))); + fx_slider( + app, + ui, + ("shadow", sh.id.0, "t"), + "Transparency", + (1.0 - sw.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "shadowProps", + "transparency", + 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); + } ui.add_space(6.0); ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { @@ -139,11 +159,41 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } }); + if let Some(r) = &fx.reflection { + fx_slider( + app, + ui, + ("refl", sh.id.0, "t"), + "Transparency", + (1.0 - r.start_alpha) * 100.0, + 0.0..=100.0, + "%", + "reflectionProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); + fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); + fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); + } ui.add_space(6.0); ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); - let mut g = sh.effects.as_ref().and_then(|e| e.glow.as_ref().map(|g| g.radius)).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text("size (pt)")).drag_stopped() { - run(app, "shape.effects", json!({"glow": if g > 0.0 { json!(g) } else { Value::Null }})); + let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); + 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)}}))); + fx_slider( + app, + ui, + ("glow", sh.id.0, "t"), + "Transparency", + (1.0 - g.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "glowProps", + "transparency", + 0.01, + ); } 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); @@ -339,6 +389,39 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } +/// A slider for one property of a shape effect: shows `value` (in display units), and when the +/// drag ends runs `shape.effects` with `{key: {prop: shown × scale}}`. +#[allow(clippy::too_many_arguments)] +fn fx_slider( + app: &mut SlideApp, + ui: &mut Ui, + id: (&str, u32, &str), + label: &str, + value: f64, + range: std::ops::RangeInclusive, + suffix: &str, + key: &str, + prop: &str, + scale: f64, +) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + // Hold the value while dragging; otherwise follow the shape. + let sid = ui.id().with(id); + let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)); + if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(sid, v)); + } else { + ui.data_mut(|d| d.remove::(sid)); + } + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + ui.data_mut(|d| d.remove::(sid)); + run(app, "shape.effects", json!({key: {prop: v * scale}})); + } + }); +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..c9767c06 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |