//! 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 { 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 { 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 { 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 { 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>> { 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>> { 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>, pic_props: Option<&Value>, transparency: Option, inherited: &HashMap>, ) -> 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 { 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 { 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 { 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 { 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 { 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) { 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"); } }