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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 21:52:56 +03:00
co-authored by Cursor
parent 457406a7a5
commit 14bc6a7ef9
4 changed files with 191 additions and 5 deletions
+70 -1
View File
@@ -60,7 +60,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|…, 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<Value> {
_ => 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<Value> {
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<f64> {
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<Fill>, Option<Line>) {
let slots = [
+34
View File
@@ -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));
}
+86 -3
View File
@@ -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<f64>,
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::<f64>(sid));
}
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
ui.data_mut(|d| d.remove::<f64>(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()));
+1 -1
View File
@@ -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 |