Files
deckcraft/crates/engine/src/cmd/shape.rs
T
Brandon Thomas 1b5d860dfc Merge pull request #1449
Format Shape Effects: preset tiles for Shadow, Glow and Soft Edges, shadow Size
2026-10-30 16:05:36 -04:00

646 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
//! names and alt text.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeId, ShapeStyle};
use serde_json::{Value, json};
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
mod appearance;
mod freeform;
mod picture;
mod size;
pub(crate) use appearance::gradient_param;
pub use appearance::quick_style_for;
use appearance::{adjust, change, effects, fill, line, picture_props, quick_style, smooth_corners};
pub use freeform::freeform_path;
use freeform::{connect, freeform, ink_to_shape, sites};
use picture::{crop, media_options, picture_adjust, picture_change, picture_reset};
use size::{clamp_pos, clamp_side, flip, rotate, size_position};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.connect",
"Connect Shapes",
[],
None,
"{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the closest pair), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}",
has_slide,
connect
),
cmd!(
"shape.freeform",
"Freeform",
["Insert", "Shapes"],
None,
"{points: [[x, y], …] slide pt (≥ 2), closed?: bool (filled shape), smooth?: bool (curve through the points)} → {id}",
has_slide,
freeform
),
cmd!(
"draw.inkToShape",
"Ink to Shape",
["Draw", "Convert"],
None,
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
has_slide,
ink_to_shape
),
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
cmd!(
"shape.move",
"Move",
[],
None,
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
has_selection,
move_by
),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
cmd!(
"shape.rotate",
"Rotation",
["Shape Format", "Arrange", "Rotate"],
None,
"{deg?: absolute, by?: relative, ids?, gesture?: move|end}",
has_selection,
rotate
),
cmd!(
"shape.sizePosition",
"Size and Position…",
["Shape Format", "Size"],
None,
"{x?, y?: pt, w?, h?: pt, rot?: deg, scaleW?, scaleH?: percent of the current size (100 = unchanged), lockAspect?: bool, ids?} — with none of those keys, opens the dialog",
has_selection,
size_position
),
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
cmd!(
"shape.smoothCorners",
"Smooth Corners",
["Shape Format", "Insert Shapes", "Edit Shape"],
None,
"{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners",
has_selection,
smooth_corners
),
cmd!(
"shape.fill",
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
cmd!(
"shape.line",
"Shape Outline",
["Shape Format", "Shape Styles"],
None,
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
has_selection,
line
),
cmd!(
"shape.effects",
"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, 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
),
cmd!(
"shape.quickStyle",
"Quick Styles",
["Home", "Drawing"],
None,
"{index: 0..41 (theme style row×accent), ids?}",
has_selection,
quick_style
),
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
cmd!(
"shape.lock",
"Lock",
["Selection Pane"],
None,
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
has_selection,
lock
),
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
cmd!(
"picture.crop",
"Crop",
["Picture Format", "Size"],
None,
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
has_selection,
crop
),
cmd!(
"picture.adjust",
"Corrections",
["Picture Format", "Adjust"],
None,
"{brightness?: -1..1, contrast?: -1..1, saturation?: 0..4, grayscale?: bool, transparency?: 0..1, reset?: bool, ids?}",
has_selection,
picture_adjust
),
cmd!(
"picture.reset",
"Reset Picture",
["Picture Format", "Adjust"],
None,
"{ids?} (discards the picture's formatting: adjustments, artistic effect, crop, transparency, picture-style effects and outline; the frame shape from Change Shape or Crop to Shape, the size and the position stay)",
has_selection,
picture_reset
),
cmd!(
"picture.change",
"Change Picture",
["Picture Format", "Adjust"],
None,
"{path? | data: base64, name?, ids?}",
has_selection,
picture_change
),
cmd!(
"media.options",
"Playback",
["Playback"],
None,
"{autoplay?, loop?, rewind?, acrossSlides?, hide?, fullScreen?, volume?: 0..1, trimStart?: ms, trimEnd?: ms, fadeIn?: ms, fadeOut?: ms, ids?}",
has_selection,
media_options
),
]
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_geometry(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
}
Ok(())
})
}
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
let lock = bool_or(p, "lockAspect", false);
edit_geometry(s, p, "shape.resize", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
match (w, h, lock) {
(Some(w), _, true) => {
x.w = w;
x.h = if ar > 0.0 { w / ar } else { x.h };
}
(None, Some(h), true) => {
x.h = h;
x.w = h * ar;
}
_ => {
x.w = w.unwrap_or(x.w);
x.h = h.unwrap_or(x.h);
}
}
x.w = clamp_side(x.w);
x.h = clamp_side(x.h);
// Rotated shapes resize about their centre; others keep the top-left.
if x.rot != 0.0 {
x.x = clamp_pos(cx - x.w / 2.0);
x.y = clamp_pos(cy - x.h / 2.0);
}
}
Ok(())
})
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_geometry(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
}
Ok(())
})
}
/// The fill a `shape.fill` request sets: `Some(None)` clears it, `Some(Some(_))` sets it, and `None`
/// leaves it as it is.
fn requested_fill(s: &mut Session, p: &Value) -> Result<Option<Option<Fill>>> {
if bool_or(p, "reset", false) {
return Ok(Some(None));
}
if bool_or(p, "none", false) {
return Ok(Some(Some(Fill::None)));
}
if bool_or(p, "background", false) {
return Ok(Some(Some(Fill::Background)));
}
if let Some(c) = color_param(p, "color") {
return Ok(Some(Some(Fill::solid(c))));
}
if let Some(g) = p.get("gradient") {
let gradient = gradient_param(g).ok_or_else(|| bad("shape.fill", "gradient needs at least two `stops`: [[0, \"#fff\"], [1, \"#000\"]]"))?;
return Ok(Some(Some(Fill::Gradient(gradient))));
}
if let Some(pt) = p.get("pattern") {
return Ok(Some(Some(Fill::Pattern(PatternFill {
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
fg: pt.get("fg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)),
bg: pt.get("bg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Bg1)),
}))));
}
if p.get("picture").is_some() {
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("shape.fill", NO_ID_SPACE)))?;
return Ok(Some(Some(Fill::Picture(PictureFill { media, ..Default::default() }))));
}
Ok(None)
}
/// The effective fill of each targeted shape, for a transparency change on an inherited (style) fill.
fn inherited_fills(s: &Session, p: &Value) -> Result<HashMap<ShapeId, Option<Fill>>> {
let st = s.doc()?;
let ctx = super::text::ctx_for(&st.doc, &st.selection);
let mut fills = HashMap::new();
for id in targets(s, p)? {
if let Some(sh) = st.shape(id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
let fill = ctx.as_ref().and_then(|c| {
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
});
fills.insert(sh.id, fill);
});
}
}
Ok(fills)
}
/// Applies a `shape.fill` request to one shape: the fill, then its picture options, then transparency.
fn apply_fill(
sh: &mut deckcraft_model::Shape,
new_fill: Option<&Option<Fill>>,
pic_props: Option<&Value>,
transparency: Option<f64>,
inherited: &HashMap<ShapeId, Option<Fill>>,
) -> Result<()> {
if let Some(f) = new_fill {
sh.fill.clone_from(f);
}
if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) {
picture_props(pf, pp);
}
if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) {
let a = 1.0 - t.clamp(0.0, 1.0);
pf.alpha = (a < 1.0).then_some(a);
return Ok(());
}
if let Some(t) = transparency {
let base = sh.fill.clone().or_else(|| inherited.get(&sh.id).cloned().flatten());
if let Some(Fill::Solid { mut color }) = base {
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if t > 0.0 {
color.mods.push(ColorTransform::Alpha(((1.0 - t.clamp(0.0, 1.0)) * 100_000.0).round() as i32));
}
sh.fill = Some(Fill::Solid { color });
}
}
Ok(())
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("shape.rename", "missing `name`"))?.to_string();
edit_shapes(s, p, "shape.rename", |sh| {
sh.name = name.clone();
Ok(())
})
}
fn alt_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let deco = bool_param(p, "decorative");
edit_shapes(s, p, "shape.altText", |sh| {
sh.descr = text.clone();
if let Some(d) = deco {
sh.decorative = d;
}
Ok(())
})
}
fn visible(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "visible");
edit_shapes(s, p, "shape.visible", |sh| {
sh.hidden = !v.unwrap_or(sh.hidden);
Ok(())
})
}
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
edit_shapes(s, p, "shape.lock", |sh| {
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
Ok(())
})
}
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
sh.locked = to;
if depth > 64 {
return;
}
if let Some(children) = sh.children_mut() {
for c in children {
set_locked(c, to, depth + 1);
}
}
}
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
// New shapes drawn in this session take this look (Set as Default Shape).
s.default_look = Some(super::format::Painted {
run: sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()),
para: None,
fill: sh.fill.clone(),
line: sh.line.clone(),
effects: sh.effects.clone(),
style: sh.style.clone(),
});
ok()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use deckcraft_model::ShapeId;
use super::*;
use serde_json::json;
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
let mut s = Session::with_new();
let mut ids = vec![];
for i in 0..4 {
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
ids.push(ShapeId(id as u32));
}
let group = |s: &mut Session, ids: &[ShapeId]| {
s.execute("edit.select", &json!({"ids": ids})).unwrap();
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
};
let inner = group(&mut s, &ids[..2]);
let outer = group(&mut s, &[inner, ids[2]]);
(s, ids[..3].to_vec(), outer, ids[3])
}
#[test]
fn group_fill_reaches_nested_members_in_one_undo_step() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
for id in &members {
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
s.execute("shape.fill", &json!({"none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
s.execute("shape.fill", &json!({"reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
}
#[test]
fn group_outline_reaches_members_without_resetting_their_other_properties() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
// Explicit ids work even when another object is selected.
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
for (i, id) in members.iter().enumerate() {
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
assert_eq!(line.width, Some((i + 1) as f64));
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
}
#[test]
fn group_fill_transparency_keeps_each_members_inherited_colour() {
let (mut s, members, group, _) = nested_group();
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
let colours: Vec<_> = {
let st = s.doc().unwrap();
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
members
.iter()
.map(|id| {
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
ctx.color(&color, ph)
})
.collect()
};
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
for (id, colour) in members.iter().zip(colours) {
let mut expected = ColorRef::rgb(colour);
expected.mods.push(ColorTransform::Alpha(50000));
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
}
}
#[test]
fn background_fill_shows_the_slide_background_and_survives_a_save() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("design.background", &json!({"color": "#0000c8"})).unwrap();
let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32);
let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone();
assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill");
s.execute("shape.fill", &json!({"background": true})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill");
s.execute("edit.redo", &json!({})).unwrap();
// Transparency changes a solid or picture fill only.
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count();
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
}
/// A picture cannot save a slide background fill, so `background` leaves its fill alone.
#[test]
fn background_fill_skips_pictures() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
let pic = s
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]}))
.unwrap()["id"]
.as_u64()
.unwrap();
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err();
assert!(err.to_string().contains("shapes and text boxes"), "{err}");
assert_eq!(fill(&s, pic), None);
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.fill", &json!({"background": true})).unwrap();
assert_eq!(fill(&s, shape), Some(Fill::Background));
assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill");
assert_eq!(fill(&s, group), None, "the group's own fill is cleared");
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let mut filled = 0;
for slide in &back.slides {
deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background)));
}
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
}
/// A picture filled with its group's fill keeps that fill behind its transparent pixels when
/// the group's shapes take the slide background fill.
#[test]
fn background_fill_keeps_the_group_fill_a_picture_uses() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png();
let pic = s
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0))));
s.edit(|doc, _| {
for slide in &mut doc.slides {
let slide = std::sync::Arc::make_mut(slide);
if let Some(g) = slide.shape_mut(ShapeId(group as u32)) {
g.fill.clone_from(&red);
}
if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) {
p.fill = Some(Fill::Group);
}
}
Ok(())
})
.unwrap();
let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone();
assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture");
s.execute("edit.select", &json!({"ids": [group]})).unwrap();
s.execute("shape.fill", &json!({"background": true})).unwrap();
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
assert_eq!(fill(&s, shape), Some(Fill::Background));
assert_eq!(fill(&s, pic), Some(Fill::Group));
assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses");
assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture");
}
fn rotation(s: &Session) -> f64 {
let st = s.active().unwrap();
let id = st.selection.shapes[0];
st.shape(id).unwrap().xfrm.unwrap().rot
}
#[test]
fn a_twist_is_one_undo_and_wraps_past_a_full_turn() {
let mut s = Session::with_new();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap();
let id = id.get("id").and_then(Value::as_u64).unwrap();
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01);
s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap();
s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s));
s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap();
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
assert!((rotation(&s) - 20.0).abs() < 0.01);
assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation");
assert!(s.active().unwrap().interaction.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist");
s.execute("edit.redo", &json!({})).unwrap();
assert!((rotation(&s) - 20.0).abs() < 0.01);
}
#[test]
fn ending_a_twist_does_not_commit_a_drag() {
let mut s = Session::with_new();
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
let st = s.active_mut().unwrap();
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind: None });
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
}
}