Merge pull request #1449

Format Shape Effects: preset tiles for Shadow, Glow and Soft Edges, shadow Size
This commit is contained in:
Brandon Thomas
2026-10-30 16:05:36 -04:00
7 changed files with 313 additions and 11 deletions
+1 -1
View File
@@ -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
),
@@ -437,6 +437,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 {
+9
View File
@@ -2328,6 +2328,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();
+4
View File
@@ -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;
+202
View File
@@ -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");
}
}
@@ -0,0 +1,79 @@
//! 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());
// A radius tile keeps the glow's colour and transparency.
h.state_mut().session.execute("shape.effects", &json!({"glowProps": {"color": "accent2", "transparency": 0.35, "radius": 8.0}})).unwrap();
h.run_steps(3);
let colour = effects(&h).glow.unwrap().color;
click(&mut h, "18 pt glow");
let glow = effects(&h).glow.unwrap();
assert_eq!(glow.radius, 18.0);
assert_eq!(glow.color, colour);
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));
}
+12 -10
View File
@@ -1,3 +1,4 @@
use super::effect_presets;
use super::{Fill, Icon, ShapeKind, SlideApp, Tokens, Ui, color_button, cref_param, json, run, scheme, theme, vec2, widgets};
/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s
@@ -47,17 +48,11 @@ fn format_tabs(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
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();
let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref());
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.inner_shadow.as_ref().or(fx.outer_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(
@@ -72,6 +67,7 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
"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);
@@ -105,6 +101,9 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
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!({"glowProps": {"radius": r}}));
}
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)}})));
@@ -123,6 +122,9 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
}
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}));
}