From 709d3d720e9bfffde5d0b1b7515f00f34f5e4c1f Mon Sep 17 00:00:00 2001 From: kinower Date: Tue, 20 Oct 2026 17:30:23 +0200 Subject: [PATCH 1/6] Crop pictures like PowerPoint: crop mode with crop handles, move and scale the picture inside Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/crop.rs | 367 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/shape.rs | 5 +- crates/engine/src/keys.rs | 4 + crates/engine/src/tests.rs | 110 ++++++++++ crates/engine/src/tools.rs | 126 ++++++++++- crates/ui-egui/src/canvas.rs | 121 ++++++++++- crates/ui-egui/src/ribbon.rs | 56 +++-- 8 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/crop.rs diff --git a/crates/engine/src/cmd/crop.rs b/crates/engine/src/cmd/crop.rs new file mode 100644 index 000000000..f86c64e4a --- /dev/null +++ b/crates/engine/src/cmd/crop.rs @@ -0,0 +1,367 @@ +//! Crop (Picture Format ▸ Size ▸ Crop), the way PowerPoint does it. +//! +//! A picture's `crop` fractions say which part of the image fills its box, so the whole image sits +//! at a rectangle of its own in the box's local points ([`image_rect`]). Cropping keeps that image +//! rectangle where it is on the slide and moves the box's edges over it: the visible part never +//! stretches. In crop mode ([`picture.cropMode`]) the box's edges are dragged with the crop +//! handles, the image is moved under the box by dragging it and scaled from its own corners. +//! +//! Everything here works in the box's local frame (before rotation and flips), so it holds for +//! rotated and flipped pictures alike. + +use deckcraft_geom::{Point, Rect, Xfrm}; +use deckcraft_model::{ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +/// Smallest box or image side a crop gesture leaves, in points. +pub const MIN_SIDE: f64 = 1.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + noundo "picture.cropMode", + "Crop", + ["Picture Format", "Size", "Crop"], + None, + "{on?: bool (default: toggle), id?} — show the crop handles on one selected picture: drag them to crop, drag the picture to move it inside, its round handles to scale it; Enter, Escape or a click elsewhere ends", + has_picture, + crop_mode + ), + cmd!( + "picture.cropAspect", + "Aspect Ratio", + ["Picture Format", "Size", "Crop", "Aspect Ratio"], + None, + "{ratio: \"w:h\" (1:1, 2:3, 3:4, 3:5, 4:5, 3:2, 4:3, 5:3, 5:4, 16:9, 16:10…), ids?} — crop to that shape, as large as the picture allows, centred", + has_picture, + crop_aspect + ), + cmd!( + "picture.cropFill", + "Fill / Fit", + ["Picture Format", "Size", "Crop"], + None, + "{mode?: fill|fit (default fill), ids?} — keep the box; fill: the picture covers it, fit: the whole picture shows inside it", + has_picture, + crop_fill + ), + ] +} + +pub fn has_picture(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let any = s.active().is_some_and(|d| d.selected_shapes().iter().any(|sh| matches!(sh.kind, ShapeKind::Picture { .. }))); + if any { Ok(()) } else { Err("select a picture".into()) } +} + +/// The whole image in the box's local points (`w`×`h` box, `crop` = left, top, right, bottom). +pub fn image_rect(w: f64, h: f64, crop: [f64; 4]) -> Option { + let [l, t, r, b] = crop; + let (fx, fy) = (1.0 - l - r, 1.0 - t - b); + if !(fx > 1e-6 && fy > 1e-6 && w.is_finite() && h.is_finite()) { + return None; + } + let (iw, ih) = (w / fx, h / fy); + let (x0, y0) = (-l * iw, -t * ih); + let r = Rect::new(x0, y0, x0 + iw, y0 + ih); + (r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()).then_some(r) +} + +/// The crop fractions that put the image at `img` in a `w`×`h` box (inverse of [`image_rect`]). +pub fn crop_of(w: f64, h: f64, img: Rect) -> Option<[f64; 4]> { + let (iw, ih) = (img.width(), img.height()); + if !(iw > 1e-6 && ih > 1e-6) { + return None; + } + let c = [-img.x0 / iw, -img.y0 / ih, (img.x1 - w) / iw, (img.y1 - h) / ih]; + c.iter().all(|v| v.is_finite()).then_some(c) +} + +/// The box that shows local rectangle `f` of box `orig` in place on the slide (rotation and flips +/// kept). Points of `orig`'s frame at `q` are at `q - f.origin()` in the new one. +pub fn frame_xfrm(orig: &Xfrm, f: Rect) -> Xfrm { + let c = orig.affine() * f.center(); + let (w, h) = (f.width(), f.height()); + Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..*orig } +} + +/// Handle positions of a local rectangle, in the order of box handles: NW, N, NE, E, SE, S, SW, W. +pub fn rect_handles(r: Rect) -> [Point; 8] { + let (cx, cy) = ((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0); + [ + Point::new(r.x0, r.y0), + Point::new(cx, r.y0), + Point::new(r.x1, r.y0), + Point::new(r.x1, cy), + Point::new(r.x1, r.y1), + Point::new(cx, r.y1), + Point::new(r.x0, r.y1), + Point::new(r.x0, cy), + ] +} + +/// Which sides a handle moves: (left, top, right, bottom). +fn sides(handle: u8) -> [bool; 4] { + match handle { + 0 => [true, true, false, false], + 1 => [false, true, false, false], + 2 => [false, true, true, false], + 3 => [false, false, true, false], + 4 => [false, false, true, true], + 5 => [false, false, false, true], + 6 => [true, false, false, true], + _ => [true, false, false, false], + } +} + +/// Crop handle `handle` of a `w`×`h` box dragged by local `d`: the new frame (orig's local points). +pub fn drag_frame(w: f64, h: f64, handle: u8, d: deckcraft_geom::Vec2) -> Rect { + let [l, t, r, b] = sides(handle); + let mut x0 = if l { d.x } else { 0.0 }; + let mut y0 = if t { d.y } else { 0.0 }; + let mut x1 = if r { w + d.x } else { w }; + let mut y1 = if b { h + d.y } else { h }; + if l { + x0 = x0.min(x1 - MIN_SIDE); + } else if r { + x1 = x1.max(x0 + MIN_SIDE); + } + if t { + y0 = y0.min(y1 - MIN_SIDE); + } else if b { + y1 = y1.max(y0 + MIN_SIDE); + } + Rect::new(x0, y0, x1, y1) +} + +/// The image's own handle `handle` dragged by local `d`, the opposite side staying put. Corners +/// keep the picture's proportions (`free` lets them go); sides stretch one way. +pub fn drag_image(img: Rect, handle: u8, d: deckcraft_geom::Vec2, free: bool) -> Rect { + let [l, t, r, b] = sides(handle); + let corner = (l || r) && (t || b); + if corner && !free { + let anchor = Point::new(if l { img.x1 } else { img.x0 }, if t { img.y1 } else { img.y0 }); + let from = Point::new(if l { img.x0 } else { img.x1 }, if t { img.y0 } else { img.y1 }); + let diag = from - anchor; + let len2 = diag.hypot2(); + if len2 <= 1e-9 { + return img; + } + let min = MIN_SIDE / img.width().min(img.height()).max(1e-9); + let s = ((from + d - anchor).dot(diag) / len2).max(min); + let to = anchor + diag * s; + return Rect::from_points(anchor, to); + } + let mut n = img; + if l { + n.x0 = (img.x0 + d.x).min(img.x1 - MIN_SIDE); + } + if r { + n.x1 = (img.x1 + d.x).max(img.x0 + MIN_SIDE); + } + if t { + n.y0 = (img.y0 + d.y).min(img.y1 - MIN_SIDE); + } + if b { + n.y1 = (img.y1 + d.y).max(img.y0 + MIN_SIDE); + } + n +} + +/// The one picture crop mode works on: given by `id` or the one selected, on the slide itself. +fn crop_target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let id = match id_param(p, "id", cmd)? { + Some(i) => i, + None => match st.selection.shapes.as_slice() { + [one] => *one, + _ => return Err(bad(cmd, "select one picture")), + }, + }; + let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?; + if !matches!(sh.kind, ShapeKind::Picture { .. }) { + return Err(bad(cmd, "only a picture can be cropped")); + } + if sh.locked { + return Err(bad(cmd, "the object is locked")); + } + if !st.shapes().iter().any(|t| t.id == id) { + return Err(bad(cmd, "a picture in a group can't be cropped; ungroup it first")); + } + Ok(id) +} + +fn crop_mode(s: &mut Session, p: &Value) -> Result { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.crop.is_none()); + if !on { + s.tool.crop = None; + return Ok(json!({"cropping": Value::Null})); + } + let id = crop_target(s, p, "picture.cropMode")?; + s.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + s.tool.edit_points = None; + s.tool.edit_vertex = None; + s.tool.crop = Some(id); + Ok(json!({"cropping": id.0})) +} + +/// Each target picture's box (slide points) and crop, before the change. +fn pictures(s: &Session, p: &Value) -> Result> { + let ids = targets(s, p)?; + let st = s.doc()?; + Ok(ids + .iter() + .filter_map(|id| { + let sh = st.shape(*id)?; + match &sh.kind { + ShapeKind::Picture { fill } if !sh.locked => Some((*id, xfrm_of(&st.doc, &st.selection, sh), fill.crop)), + _ => None, + } + }) + .collect()) +} + +/// Store a new box and crop on each picture (`None` leaves the box). +pub(crate) fn store(s: &mut Session, cmd: &str, items: &[(ShapeId, Option, [f64; 4])]) -> Result { + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + for (id, x, c) in items { + if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) { + if let Some(x) = x { + sh.xfrm = Some(*x); + } + if let ShapeKind::Picture { fill } = &mut sh.kind { + fill.crop = *c; + } + } + } + Ok(()) + })?; + Ok(json!({"cropped": items.len()})) +} + +/// `picture.crop` with the frame following the crop (PowerPoint): the image keeps its size and +/// place and the box shrinks or grows over it. +pub(crate) fn crop_keeping_scale(s: &mut Session, p: &Value, want: [Option; 4]) -> Result { + let mut out = vec![]; + for (id, x, old) in pictures(s, p)? { + let Some(img) = image_rect(x.w, x.h, old) else { continue }; + let mut c = old; + for (slot, v) in c.iter_mut().zip(want) { + if let Some(v) = v.filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.95); + } + } + // Keep at least a sliver showing either way. + if c[0] + c[2] > 0.99 || c[1] + c[3] > 0.99 { + return Err(bad("picture.crop", "the crop leaves nothing of the picture")); + } + let (iw, ih) = (img.width(), img.height()); + let f = Rect::new(img.x0 + c[0] * iw, img.y0 + c[1] * ih, img.x1 - c[2] * iw, img.y1 - c[3] * ih); + out.push((id, Some(frame_xfrm(&x, f)), c)); + } + store(s, "picture.crop", &out) +} + +fn parse_ratio(v: &Value) -> Option { + let r = match v { + Value::String(t) => { + let (a, b) = t.split_once([':', '/', 'x'])?; + a.trim().parse::().ok()? / b.trim().parse::().ok()? + } + Value::Array(a) => a.first()?.as_f64()? / a.get(1)?.as_f64()?, + _ => v.as_f64()?, + }; + (r.is_finite() && (0.01..=100.0).contains(&r)).then_some(r) +} + +fn crop_aspect(s: &mut Session, p: &Value) -> Result { + let ratio = p.get("ratio").and_then(parse_ratio).ok_or_else(|| bad("picture.cropAspect", "ratio must be like \"16:9\""))?; + let mut out = vec![]; + for (id, x, c) in pictures(s, p)? { + let Some(img) = image_rect(x.w, x.h, c) else { continue }; + // The largest box of that shape inside the picture, centred on it. + let (iw, ih) = (img.width(), img.height()); + let (w, h) = if iw / ih > ratio { (ih * ratio, ih) } else { (iw, iw / ratio) }; + let ctr = img.center(); + let f = Rect::new(ctr.x - w / 2.0, ctr.y - h / 2.0, ctr.x + w / 2.0, ctr.y + h / 2.0); + let Some(nc) = crop_of(w, h, img - f.origin().to_vec2()) else { continue }; + out.push((id, Some(frame_xfrm(&x, f)), nc)); + } + let r = store(s, "picture.cropAspect", &out)?; + // Then straight into crop mode to adjust it, as PowerPoint does. + if let [(id, ..)] = out.as_slice() { + s.tool.crop = Some(*id); + } + Ok(r) +} + +fn crop_fill(s: &mut Session, p: &Value) -> Result { + let fit = match str_param(p, "mode").unwrap_or("fill") { + "fill" => false, + "fit" => true, + m => return Err(bad("picture.cropFill", format!("mode {m:?} is not fill or fit"))), + }; + let mut out = vec![]; + for (id, x, c) in pictures(s, p)? { + let Some(img) = image_rect(x.w, x.h, c) else { continue }; + let (kx, ky) = (x.w / img.width(), x.h / img.height()); + let k = if fit { kx.min(ky) } else { kx.max(ky) }; + let (w, h) = (img.width() * k, img.height() * k); + let (x0, y0) = ((x.w - w) / 2.0, (x.h - h) / 2.0); + let Some(nc) = crop_of(x.w, x.h, Rect::new(x0, y0, x0 + w, y0 + h)) else { continue }; + out.push((id, None, nc)); + } + store(s, "picture.cropFill", &out) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_geom::Vec2; + + #[test] + fn image_rect_and_crop_of_round_trip() { + let c = [0.1, 0.2, 0.3, 0.05]; + let img = image_rect(60.0, 75.0, c).unwrap(); + assert!((img.width() - 100.0).abs() < 1e-9 && (img.height() - 100.0).abs() < 1e-9); + let back = crop_of(60.0, 75.0, img).unwrap(); + for (a, b) in back.iter().zip(c) { + assert!((a - b).abs() < 1e-9); + } + assert!(image_rect(10.0, 10.0, [0.5, 0.0, 0.5, 0.0]).is_none()); + } + + #[test] + fn frame_handles_keep_a_minimum_size() { + let f = drag_frame(100.0, 50.0, 3, Vec2::new(-500.0, 0.0)); + assert!((f.width() - MIN_SIDE).abs() < 1e-9); + let f = drag_frame(100.0, 50.0, 0, Vec2::new(10.0, 5.0)); + assert_eq!((f.x0, f.y0, f.x1, f.y1), (10.0, 5.0, 100.0, 50.0)); + } + + #[test] + fn image_corners_keep_proportions() { + let img = Rect::new(0.0, 0.0, 200.0, 100.0); + let n = drag_image(img, 4, Vec2::new(200.0, 0.0), false); + assert!((n.width() / n.height() - 2.0).abs() < 1e-9); + assert_eq!((n.x0, n.y0), (0.0, 0.0)); + let n = drag_image(img, 3, Vec2::new(50.0, 0.0), false); + assert_eq!((n.width(), n.height()), (250.0, 100.0)); + } + + #[test] + fn ratios_parse() { + assert!((parse_ratio(&json!("16:9")).unwrap() - 16.0 / 9.0).abs() < 1e-12); + assert_eq!(parse_ratio(&json!([4, 3])), Some(4.0 / 3.0)); + assert!(parse_ratio(&json!("0:3")).is_none()); + assert!(parse_ratio(&json!("a:b")).is_none()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5695cc242..244adc78a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod arrange; pub mod artistic; pub mod background; pub mod compress; +pub mod crop; pub mod design; pub mod edit; pub mod file; @@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(selection::specs()); v.extend(merge::specs()); v.extend(points::specs()); + v.extend(crop::specs()); v.extend(text::specs()); v.extend(format::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index f1a050d1a..8dac09fcd 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Crop", ["Picture Format", "Size"], None, - "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}", + "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, keepFrame?: bool (default false: the box follows the crop and the picture keeps its size, as in PowerPoint; true: the box stays and the rest stretches into it), ids?}", has_selection, crop ), @@ -1053,6 +1053,9 @@ fn set_default(s: &mut Session, _p: &Value) -> Result { fn crop(s: &mut Session, p: &Value) -> Result { let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")]; + if !bool_or(p, "keepFrame", false) { + return super::crop::crop_keeping_scale(s, p, c); + } edit_geometry(s, p, "picture.crop", |sh| { if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { for (i, v) in c.iter().enumerate() { diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 38b5aa713..846cb2ff7 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -57,6 +57,10 @@ impl Session { if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } + if self.tool.crop.is_some() && matches!(k, "enter" | "return" | "escape") { + self.tool.crop = None; + return Ok(json!({"handled": true, "command": "picture.cropMode"})); + } if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { self.tool.edit_points = None; self.tool.edit_vertex = None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08cd..48dacbecb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4072,3 +4072,113 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } + +/// A 1×1 PNG inserted at [100, 100, 200, 150]; returns its id. +fn cropped_picture(s: &mut Session) -> u64 { + const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, + 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, + 0x82, + ]; + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let r = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]})); + r.unwrap()["id"].as_u64().unwrap() +} + +/// The picture's box (x, y, w, h) and crop. +fn crop_state(s: &Session, id: u64) -> ([f64; 4], [f64; 4]) { + let st = s.doc().unwrap(); + let sh = st.shapes().iter().find(|sh| sh.id.0 as u64 == id).unwrap(); + let x = sh.xfrm.unwrap(); + let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("not a picture") }; + ([x.x, x.y, x.w, x.h], fill.crop) +} + +fn near(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6) +} + +#[test] +fn crop_command_moves_the_box_over_the_picture_like_powerpoint() { + let mut s = session(); + let id = cropped_picture(&mut s); + s.execute("picture.crop", &json!({"ids": [id], "left": 0.25, "bottom": 0.2})).unwrap(); + let (b, c) = crop_state(&s, id); + // The picture keeps its size and place: the box loses a quarter on the left, a fifth below. + assert!(near(b, [150.0, 100.0, 150.0, 120.0]), "{b:?}"); + assert!(near(c, [0.25, 0.0, 0.0, 0.2]), "{c:?}"); + // Resetting the crop brings the whole picture back at the same scale. + s.execute("picture.crop", &json!({"ids": [id], "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})).unwrap(); + assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0])); + // keepFrame: the old behaviour, the box stays and the rest stretches into it. + s.execute("picture.crop", &json!({"ids": [id], "left": 0.5, "keepFrame": true})).unwrap(); + assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0])); + assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6})).is_err(), "nothing would be left"); +} + +#[test] +fn crop_mode_handles_crop_move_and_scale_the_picture() { + let mut s = session(); + let id = cropped_picture(&mut s); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + let r = s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + assert_eq!(r["cropping"], id); + // The right crop handle, dragged in by 50. + let hit = s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap(); + assert_eq!(hit["hit"]["kind"], "crop"); + s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 250.0, 175.0)).unwrap(); + let (b, c) = crop_state(&s, id); + assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}"); + assert!(near(c, [0.0, 0.0, 0.25, 0.0]), "{c:?}"); + assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _)), "still cropping"); + // Drag the picture 20 to the left under the box. + s.pointer(ev(PointerKind::Down, 200.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 180.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 180.0, 175.0)).unwrap(); + let (b, c) = crop_state(&s, id); + assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "the box stays: {b:?}"); + assert!(near(c, [0.1, 0.0, 0.15, 0.0]), "{c:?}"); + // Scale the picture from its bottom-right corner (now at 280, 250): proportions kept. + s.pointer(ev(PointerKind::Down, 280.0, 250.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 380.0, 300.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 380.0, 300.0)).unwrap(); + let (b, c) = crop_state(&s, id); + assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}"); + let img = cmd::crop::image_rect(b[2], b[3], c).unwrap(); + assert!((img.width() / img.height() - 200.0 / 150.0).abs() < 1e-6, "{img:?}"); + assert!((img.x0 + 20.0).abs() < 1e-6 && img.y0.abs() < 1e-6, "the far corner stays: {img:?}"); + // Each gesture is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(crop_state(&s, id).1, [0.1, 0.0, 0.15, 0.0])); + // Escape ends crop mode; so does a click away from the picture. + s.key("Escape", Mods::default()).unwrap(); + assert_eq!(s.tool.crop, None); + s.execute("picture.cropMode", &json!({})).unwrap(); + assert!(s.tool.crop.is_some()); + s.pointer(ev(PointerKind::Down, 600.0, 500.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 600.0, 500.0)).unwrap(); + assert_eq!(s.tool.crop, None); +} + +#[test] +fn crop_aspect_fill_and_fit() { + let mut s = session(); + let id = cropped_picture(&mut s); + s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "1:1"})).unwrap(); + let (b, _) = crop_state(&s, id); + assert!(near(b, [125.0, 100.0, 150.0, 150.0]), "largest square, centred: {b:?}"); + assert!(s.tool.crop.is_some(), "then into crop mode"); + // Fit: the whole picture inside the square box; fill: the box covered again. + s.execute("picture.cropFill", &json!({"ids": [id], "mode": "fit"})).unwrap(); + let (b, c) = crop_state(&s, id); + let img = cmd::crop::image_rect(b[2], b[3], c).unwrap(); + assert!((img.width() - 150.0).abs() < 1e-6 && (img.height() - 112.5).abs() < 1e-6, "{img:?}"); + s.execute("picture.cropFill", &json!({"ids": [id]})).unwrap(); + let (b, c) = crop_state(&s, id); + let img = cmd::crop::image_rect(b[2], b[3], c).unwrap(); + assert!((img.height() - 150.0).abs() < 1e-6 && (img.width() - 200.0).abs() < 1e-6, "{img:?}"); + assert!(s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "wide"})).is_err()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a7190b734..0c577e5b2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -125,6 +125,28 @@ pub enum Hit { id: ShapeId, row: usize, }, + /// Crop mode: crop handle 0..8 (NW, N, NE, E, SE, S, SW, W) on the picture's box. + Crop { + id: ShapeId, + handle: u8, + }, + /// Crop mode: handle 0..8 of the whole image, which scales it under the box. + CropImage { + id: ShapeId, + handle: u8, + }, + /// Crop mode: the picture itself, which moves under the box. + CropPan { + id: ShapeId, + }, +} + +/// What a crop-mode drag changes. +#[derive(Clone, Copy, Debug)] +enum CropPart { + Frame(u8), + Image(u8), + Pan, } #[derive(Clone, Debug)] @@ -207,6 +229,14 @@ enum Drag { Ink { points: Vec<(f64, f64, f32)>, }, + /// Crop mode: `orig` is the box and `img` the whole image in its local points at the press. + Crop { + id: ShapeId, + part: CropPart, + orig: Xfrm, + img: Rect, + start: Point, + }, /// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while /// the button is down (dragging adds scribbled points). Freeform { @@ -271,6 +301,8 @@ pub struct ToolState { pub guide_hover: Option, /// Pressure from the pointer event being handled, if the device reported one. pressure: Option, + /// The picture in crop mode (Picture Format ▸ Crop): its crop handles replace the box's. + pub crop: Option, } impl ToolState { @@ -288,6 +320,15 @@ impl ToolState { }) } + /// The picture in crop mode, while it is still the one selected shape. + pub fn cropping(&self, st: &crate::DocState) -> Option { + self.crop.filter(|id| { + st.selection.shapes.as_slice() == [*id] + && st.selection.text.is_none() + && st.shape(*id).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. }) && !s.locked) + }) + } + /// A freeform path is being drawn (Enter/Escape/double-click finish it). pub fn drawing_freeform(&self) -> bool { matches!(self.drag, Some(Drag::Freeform { .. })) @@ -357,6 +398,30 @@ impl Session { /// What's under `p` on the current slide. pub fn hit_test(&self, p: Point, tol: f64) -> Option { let st = self.active()?; + // In crop mode the picture's crop handles, then the image's own, then the image itself. + if let Some(id) = self.tool.cropping(st) + && let Some(sh) = st.shape(id) + && let ShapeKind::Picture { fill } = &sh.kind + { + let x = xfrm_of(&st.doc, &st.selection, sh); + let a = x.affine(); + let frame = Rect::new(0.0, 0.0, x.w, x.h); + for (i, q) in cmd::crop::rect_handles(frame).iter().enumerate() { + if (a * *q - p).hypot() <= tol * 2.0 { + return Some(Hit::Crop { id, handle: i as u8 }); + } + } + if let Some(img) = cmd::crop::image_rect(x.w, x.h, fill.crop) { + for (i, q) in cmd::crop::rect_handles(img).iter().enumerate() { + if (a * *q - p).hypot() <= tol * 1.6 { + return Some(Hit::CropImage { id, handle: i as u8 }); + } + } + if img.union(frame).contains(a.inverse() * p) { + return Some(Hit::CropPan { id }); + } + } + } // In Edit Points, the edited shape's vertices come first. if let Some(id) = self.tool.editing_points(st) && let Some(sh) = st.shape(id) @@ -557,6 +622,30 @@ impl Session { return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); + // Crop mode: the crop handles, the image's handles and the image itself; a press anywhere + // else ends it and goes on as a normal press. + if let Some(cid) = self.doc().ok().and_then(|st| self.tool.cropping(st)) { + let part = match hit { + Some(Hit::Crop { id, handle }) if id == cid => Some(CropPart::Frame(handle)), + Some(Hit::CropImage { id, handle }) if id == cid => Some(CropPart::Image(handle)), + Some(Hit::CropPan { id }) if id == cid => Some(CropPart::Pan), + _ => None, + }; + let st = self.doc()?; + let found = st.shape(cid).and_then(|sh| match &sh.kind { + ShapeKind::Picture { fill } => { + let x = xfrm_of(&st.doc, &st.selection, sh); + cmd::crop::image_rect(x.w, x.h, fill.crop).map(|img| (x, img)) + } + _ => None, + }); + if let (Some(part), Some((orig, img))) = (part, found) { + self.begin("Crop"); + self.tool.drag = Some(Drag::Crop { id: cid, part, orig, img, start: p }); + return Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})); + } + self.tool.crop = None; + } // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; // a click anywhere else leaves Edit Points. if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { @@ -608,8 +697,8 @@ impl Session { self.begin("Rotate"); self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); } - // Only reached outside Edit Points (handled above). - Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} + // Only reached outside Edit Points and crop mode (handled above). + Some(Hit::Vertex { .. } | Hit::Handle { .. } | Hit::Crop { .. } | Hit::CropImage { .. } | Hit::CropPan { .. }) => {} Some(Hit::Adjust { id, index }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Adjust Shape"); @@ -764,6 +853,34 @@ impl Session { self.tool.drag = Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); } + Drag::Crop { id, part, orig, img, start } => { + let inv = orig.affine().inverse(); + let mut d = inv * p - inv * start; + if !(d.x.is_finite() && d.y.is_finite()) { + return Ok(Value::Null); + } + let (nx, nc) = match part { + CropPart::Frame(h) => { + let f = cmd::crop::drag_frame(orig.w, orig.h, h, d); + let c = cmd::crop::crop_of(f.width(), f.height(), img - f.origin().to_vec2()); + (cmd::crop::frame_xfrm(&orig, f), c) + } + CropPart::Image(h) => (orig, cmd::crop::crop_of(orig.w, orig.h, cmd::crop::drag_image(img, h, d, mods.shift))), + CropPart::Pan => { + if mods.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + (orig, cmd::crop::crop_of(orig.w, orig.h, img + d)) + } + }; + if let Some(c) = nc { + cmd::crop::store(self, "crop", &[(id, Some(nx), c)])?; + } + } Drag::Resize { id, handle, orig, start } => { let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); let nx = self.snap_resize(nx, &orig, handle, id, tol); @@ -1202,7 +1319,10 @@ impl Session { | Hit::Frame { id } | Hit::TableColumn { id, .. } | Hit::TableRow { id, .. } - | Hit::Warp { id, .. }, + | Hit::Warp { id, .. } + | Hit::Crop { id, .. } + | Hit::CropImage { id, .. } + | Hit::CropPan { id }, ) => Some(*id), None => None, }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad81..f789a2696 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -758,7 +758,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, (ToolKind::TextBox, _) => egui::CursorIcon::Text, (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, - (_, Some(Hit::Resize { handle, .. })) => match handle { + (_, Some(Hit::CropPan { .. })) => egui::CursorIcon::Move, + (_, Some(Hit::Resize { handle, .. } | Hit::Crop { handle, .. } | Hit::CropImage { handle, .. })) => match handle { 0 | 4 => egui::CursorIcon::ResizeNwSe, 2 | 6 => egui::CursorIcon::ResizeNeSw, 1 | 5 => egui::CursorIcon::ResizeVertical, @@ -790,6 +791,114 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { } } +/// Crop mode, as in PowerPoint: the part of the picture outside its box shows faded with round +/// handles at its edges (they scale it), and the box carries black crop marks. +fn crop_overlay( + ctx: &egui::Context, + doc: &deckcraft_model::Presentation, + painter: &egui::Painter, + xf: Xf, + x: &Xfrm, + fill: &deckcraft_model::style::PictureFill, + t: &Tokens, +) { + use deckcraft_engine::cmd::crop; + let a = x.affine(); + let to = |q: Point| xf.to_screen(a * q); + let frame = deckcraft_geom::Rect::new(0.0, 0.0, x.w, x.h); + if let Some(img) = crop::image_rect(x.w, x.h, fill.crop) { + if let Some(tex) = crop_ghost(ctx, doc, fill.media) { + // The image outside the box, in bands above, below, left and right of it. + let (y0, y1) = (img.y0.max(frame.y0), img.y1.min(frame.y1)); + let bands = [ + (img.x0, img.y0, img.x1, img.y1.min(frame.y0)), + (img.x0, img.y0.max(frame.y1), img.x1, img.y1), + (img.x0, y0, img.x1.min(frame.x0), y1), + (img.x0.max(frame.x1), y0, img.x1, y1), + ]; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let tint = Color32::from_white_alpha(120); + for (bx0, by0, bx1, by1) in bands { + if bx1 - bx0 <= 1e-6 || by1 - by0 <= 1e-6 { + continue; + } + let base = mesh.vertices.len() as u32; + for (px, py) in [(bx0, by0), (bx1, by0), (bx1, by1), (bx0, by1)] { + let uv = pos2(((px - img.x0) / img.width()) as f32, ((py - img.y0) / img.height()) as f32); + mesh.vertices.push(egui::epaint::Vertex { pos: to(Point::new(px, py)), uv, color: tint }); + } + mesh.add_triangle(base, base + 1, base + 2); + mesh.add_triangle(base, base + 2, base + 3); + } + painter.add(mesh); + } + let ic = [Point::new(img.x0, img.y0), Point::new(img.x1, img.y0), Point::new(img.x1, img.y1), Point::new(img.x0, img.y1)].map(to); + poly(painter, ic, Stroke::new(1.0, Color32::from_gray(150))); + for q in crop::rect_handles(img) { + painter.circle(to(q), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); + } + } + let fc = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(to); + poly(painter, fc, Stroke::new(1.0, t.selection)); + // Crop marks: an L at each corner and a bar mid-side, along the box's edges. + let dir = |q: Point, d: deckcraft_geom::Vec2| { + let v = to(q + d) - to(q); + if v.length() > 1e-3 { v.normalized() } else { egui::Vec2::ZERO } + }; + let (ux, uy) = (deckcraft_geom::Vec2::new(1.0, 0.0), deckcraft_geom::Vec2::new(0.0, 1.0)); + let len = 12.0; + for (i, q) in crop::rect_handles(frame).into_iter().enumerate() { + let c = to(q); + let arms: Vec<(Pos2, Pos2)> = match i { + 0 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, uy) * len)], + 2 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, uy) * len)], + 4 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, -uy) * len)], + 6 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, -uy) * len)], + 1 | 5 => vec![(c - dir(q, ux) * len * 0.75, c + dir(q, ux) * len * 0.75)], + _ => vec![(c - dir(q, uy) * len * 0.75, c + dir(q, uy) * len * 0.75)], + }; + for (s, e) in &arms { + painter.line_segment([*s, *e], Stroke::new(6.0, Color32::WHITE)); + } + for (s, e) in arms { + painter.line_segment([s, e], Stroke::new(4.0, Color32::BLACK)); + } + } +} + +/// The picture drawn whole, for the faded part outside the box in crop mode. Decoded once per +/// picture and kept at most 1024 px on a side; `None` when it doesn't decode. +fn crop_ghost(ctx: &egui::Context, doc: &deckcraft_model::Presentation, media: deckcraft_model::MediaId) -> Option { + const SIDE: u32 = 1024; + let item = doc.media(media)?; + let key = egui::Id::new(("crop-ghost", std::sync::Arc::as_ptr(&item.data) as usize, item.data.len())); + if let Some(t) = ctx.data(|d| d.get_temp::>(key)) { + return t; + } + let tex = deckcraft_render::decode_image(&item.data).and_then(|im| { + let (w, h) = (im.width(), im.height()); + if w == 0 || h == 0 { + return None; + } + let k = (SIDE as f64 / w.max(h) as f64).min(1.0); + let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)); + let raw = im.as_raw(); + let mut px = Vec::with_capacity((nw as usize) * (nh as usize) * 4); + for y in 0..nh { + let sy = ((y as u64 * h as u64) / nh as u64).min(h as u64 - 1) as usize; + for x in 0..nw { + let sx = ((x as u64 * w as u64) / nw as u64).min(w as u64 - 1) as usize; + let i = (sy * w as usize + sx) * 4; + px.extend_from_slice(raw.get(i..i + 4).unwrap_or(&[0, 0, 0, 0])); + } + } + let image = egui::ColorImage::from_rgba_unmultiplied([nw as usize, nh as usize], &px); + Some(ctx.load_texture("crop-ghost", image, egui::TextureOptions::LINEAR)) + }); + ctx.data_mut(|d| d.insert_temp(key, tex.clone())); + tex +} + fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { for i in 0..4 { p.line_segment([pts[i], pts[(i + 1) % 4]], stroke); @@ -827,6 +936,12 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To continue; } let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); + if app.session.tool.cropping(st) == Some(sh.id) + && let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind + { + crop_overlay(ui.ctx(), &st.doc, painter, xf, &x, fill, t); + continue; + } if app.session.tool.editing_points(st) == Some(sh.id) { // Edit Points: the outline's vertices are the handles. // No selection box: the outline and its vertices are what's edited. @@ -1248,6 +1363,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("app.changePictureDialog", json!({})); ui.close(); } + if picture && crate::ribbon::enabled(app, "picture.cropMode") && ui.button("Crop").clicked() { + let _ = app.run("picture.cropMode", json!({"on": true})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3a679dd11..dc76cdd0f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2735,19 +2735,51 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { }); arrange_group(app, ui); group(ui, |ui| { - let r = big_button(ui, Icon::Crop, "Crop", true); - egui::Popup::menu(&r).show(|ui| { - ui.label("Crop to Shape"); - if let Some(p) = shapes_gallery(ui) { - run(app, "shape.change", json!({"preset": p})); - } - ui.separator(); - for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { - if ui.button(l).clicked() { - run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); + // Split like PowerPoint's: the top enters (or leaves) crop mode, the bottom opens the menu. + let en = enabled(app, "picture.cropMode"); + let on = app.session.active().is_some_and(|d| app.session.tool.cropping(d).is_some()); + let (main, arrow) = big_button_ex(ui, Icon::Crop, "Crop", en, true, on); + let main = main.on_hover_text(tip("picture.cropMode", "Crop")); + if main.clicked() { + run(app, "picture.cropMode", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + if ui.button("Crop").clicked() { + run(app, "picture.cropMode", json!({"on": true})); } - } - }); + ui.menu_button("Crop to Shape", |ui| { + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + }); + ui.menu_button("Aspect Ratio", |ui| { + for (head, ratios) in [ + ("Square", &["1:1"][..]), + ("Portrait", &["2:3", "3:4", "3:5", "4:5"][..]), + ("Landscape", &["3:2", "4:3", "5:3", "5:4", "16:9", "16:10"][..]), + ] { + ui.label(head); + for r in ratios { + if ui.button(*r).clicked() { + run(app, "picture.cropAspect", json!({"ratio": r})); + } + } + } + }); + ui.separator(); + if ui.button("Fill").on_hover_text("The picture covers the whole box").clicked() { + run(app, "picture.cropFill", json!({"mode": "fill"})); + } + if ui.button("Fit").on_hover_text("The whole picture shows inside the box").clicked() { + run(app, "picture.cropFill", json!({"mode": "fit"})); + } + ui.separator(); + if ui.button("Reset Crop").clicked() { + run(app, "picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})); + } + }); + } }); size_group(app, ui); } From c5fc5d5df8ba9b093eee4f92ada4dd7c96866e39 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 01:01:07 +0200 Subject: [PATCH 2/6] End crop mode when its picture stops being selected tool.crop stayed set after Tab, slide navigation or Undo moved the selection away, so the crop handles came back when the picture was selected again, the first Enter/Escape was swallowed and picture.cropMode toggled the wrong way. Session::settle_crop clears it whenever cropping() no longer holds: after Session::select, after every command (including undo/redo restore), and before key and pointer handling. Reselecting the same picture keeps it on. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/keys.rs | 1 + crates/engine/src/lib.rs | 2 ++ crates/engine/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 10 +++++++++ 5 files changed, 54 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fdb30f1ed..1c2f7da43 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -52,6 +52,7 @@ fn restore(s: &mut Session, redo: bool) -> Result { st.selection = e.selection; st.interaction = None; st.revision += 1; + s.settle_crop(); Ok(json!({"label": e.label})) } diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 846cb2ff7..9db319177 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -57,6 +57,7 @@ impl Session { if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } + self.settle_crop(); if self.tool.crop.is_some() && matches!(k, "enter" | "return" | "escape") { self.tool.crop = None; return Ok(json!({"handled": true, "command": "picture.cropMode"})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 418bff04d..0214c4a2e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -619,6 +619,7 @@ impl Session { if let Some(st) = self.active_mut() { clamp_selection(st); } + self.settle_crop(); if let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut()) && st.uid == uid && !Arc::ptr_eq(&old, &st.doc) @@ -681,6 +682,7 @@ impl Session { if st.composition.is_some() && owner(&st.selection) != before { self.end_composition(); } + self.settle_crop(); Ok(()) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 48dacbecb..cf49f90a6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4163,6 +4163,46 @@ fn crop_mode_handles_crop_move_and_scale_the_picture() { assert_eq!(s.tool.crop, None); } +#[test] +fn crop_mode_ends_when_the_picture_stops_being_selected() { + let mut s = session(); + let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let id = cropped_picture(&mut s); + let pic = deckcraft_model::ShapeId(id as _); + let cropping = |s: &Session| s.tool.cropping(s.doc().unwrap()); + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + // Selecting the same picture again keeps crop mode on. + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.select(|_, sel| sel.shapes = vec![pic]).unwrap(); + assert_eq!(s.tool.crop, Some(pic)); + // Tab moves to the other shape: crop mode is over, and coming back doesn't bring it back. + s.key("Tab", Mods::default()).unwrap(); + assert_ne!(s.doc().unwrap().selection.shapes, vec![pic]); + assert_eq!(s.tool.crop, None); + for _ in 0..10 { + if s.doc().unwrap().selection.shapes == vec![pic] { + break; + } + s.key("Tab", Mods::default()).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]); + assert_eq!(cropping(&s), None); + let r = s.key("Enter", Mods::default()); + assert!(!r.is_ok_and(|v| v["command"] == "picture.cropMode"), "Enter isn't taken by a crop mode that ended"); + s.execute("edit.deselect", &json!({})).unwrap(); + // The toggle starts crop mode again rather than turning off a stale one. + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("picture.cropMode", &json!({})).unwrap()["cropping"], id); + // Undo takes the picture away (the rectangle is selected again); Redo brings it back + // selected, but not in crop mode. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.shapes, vec![deckcraft_model::ShapeId(rect as _)]); + assert_eq!(s.tool.crop, None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]); + assert_eq!(cropping(&s), None); +} + #[test] fn crop_aspect_fill_and_fit() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 0c577e5b2..90422a214 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -388,6 +388,15 @@ fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f } impl Session { + /// End crop mode once its picture is no longer the one selected shape (Tab, a slide change, + /// Undo…), so its handles don't come back when the picture is selected again. Reselecting the + /// same picture keeps crop mode on. + pub(crate) fn settle_crop(&mut self) { + if self.tool.crop.is_some() && self.active().is_none_or(|st| self.tool.cropping(st).is_none()) { + self.tool.crop = None; + } + } + pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; self.tool.drag = None; @@ -513,6 +522,7 @@ impl Session { if !matches!(ev.kind, PointerKind::Move) { self.end_composition(); } + self.settle_crop(); match ev.kind { PointerKind::Move => { if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { From 5132635d754a3774f9315fa68956c73b0ac0a828 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 08:12:45 +0200 Subject: [PATCH 3/6] End crop mode when another presentation becomes active set_active, add_document and close_document switched the active document without settling crop mode, so switching A -> B -> A brought back the old picture's crop handles. They now clear it whenever the active document changes and run settle_crop() afterwards; re-activating the same presentation or closing another one keeps it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 17 +++++++++++++++++ crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0214c4a2e..43800e7ae 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -554,23 +554,40 @@ impl Session { } pub fn set_active(&mut self, i: usize) { if i < self.docs.len() { + let before = self.active_uid(); self.active = Some(i); + self.document_changed(before); } } pub fn add_document(&mut self, d: DocState) -> usize { + let before = self.active_uid(); self.docs.push(d); self.active = Some(self.docs.len() - 1); + self.document_changed(before); self.docs.len() - 1 } pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { + let before = self.active_uid(); let d = self.docs.remove(i); if let Some(dir) = &self.recovery_dir { recovery::discard(dir, d.uid); } self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; + self.document_changed(before); } } + fn active_uid(&self) -> Option { + self.active().map(|d| d.uid) + } + /// Crop mode belongs to a picture in one presentation: another one becoming active ends it, + /// so coming back doesn't bring it back. + fn document_changed(&mut self, before: Option) { + if self.active_uid() != before { + self.tool.crop = None; + } + self.settle_crop(); + } pub fn next_untitled(&mut self) -> String { self.untitled += 1; format!("Presentation{}", self.untitled) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cf49f90a6..7fb99b9ba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4203,6 +4203,31 @@ fn crop_mode_ends_when_the_picture_stops_being_selected() { assert_eq!(cropping(&s), None); } +#[test] +fn crop_mode_ends_when_another_presentation_becomes_active() { + let mut s = session(); + let id = cropped_picture(&mut s); + let pic = deckcraft_model::ShapeId(id as _); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + // Activating the presentation that is already active keeps crop mode on. + s.set_active(0); + assert_eq!(s.tool.crop, Some(pic)); + // A → B ends it; back in A the picture is still selected, but not in crop mode. + s.set_active(1); + assert_eq!(s.tool.crop, None); + s.set_active(0); + assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]); + assert_eq!(s.tool.cropping(s.doc().unwrap()), None); + // Closing another presentation keeps it; closing this one ends it. + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + s.close_document(1); + assert_eq!(s.tool.crop, Some(pic)); + s.close_document(0); + assert_eq!(s.tool.crop, None); +} + #[test] fn crop_aspect_fill_and_fit() { let mut s = session(); From 2bba9cc57571546975a96363b7a5b589c70ea79d Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 09:52:48 +0200 Subject: [PATCH 4/6] Finish a crop drag in its own presentation before switching documents If set_active, add_document or close_document ran in the middle of a crop gesture, the Drag::Crop stayed in the tool state: the next pointer events cropped the newly active presentation, and the original one kept an open Crop interaction with no undo boundary. end_crop_drag() now drops the drag and commits the Crop interaction on the original presentation, as releasing the pointer does, before the active document changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 7 +++++++ crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 10 ++++++++++ 3 files changed, 45 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 43800e7ae..e02d693e4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -555,12 +555,16 @@ impl Session { pub fn set_active(&mut self, i: usize) { if i < self.docs.len() { let before = self.active_uid(); + if before != self.docs.get(i).map(|d| d.uid) { + self.end_crop_drag(); + } self.active = Some(i); self.document_changed(before); } } pub fn add_document(&mut self, d: DocState) -> usize { let before = self.active_uid(); + self.end_crop_drag(); self.docs.push(d); self.active = Some(self.docs.len() - 1); self.document_changed(before); @@ -569,6 +573,9 @@ impl Session { pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { let before = self.active_uid(); + if self.active == Some(i) { + self.end_crop_drag(); + } let d = self.docs.remove(i); if let Some(dir) = &self.recovery_dir { recovery::discard(dir, d.uid); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7fb99b9ba..29a087b0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4228,6 +4228,34 @@ fn crop_mode_ends_when_another_presentation_becomes_active() { assert_eq!(s.tool.crop, None); } +#[test] +fn a_crop_drag_ends_in_its_own_presentation_when_another_becomes_active() { + let mut s = session(); + let id = cropped_picture(&mut s); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + // Halfway through dragging the right crop handle in, a new presentation opens. + s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + assert!(!s.tool.dragging() && s.tool.crop.is_none()); + let b = s.doc().unwrap().doc.clone(); + s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap(); + let st = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&b, &st.doc) && st.history.undo.is_empty(), "the new presentation is untouched"); + // The first presentation kept the crop so far as one finished undo step. + s.set_active(0); + let st = s.doc().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(st.history.undo.len(), undo_before + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Crop"); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0])); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.0, 0.0])); +} + #[test] fn crop_aspect_fill_and_fit() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 90422a214..d244a634a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -397,6 +397,16 @@ impl Session { } } + /// Finish a crop gesture in the presentation it began in, as releasing the pointer does, + /// before another presentation becomes active or this one closes: one undo step there, and + /// later pointer events can't carry it into another presentation. + pub(crate) fn end_crop_drag(&mut self) { + if matches!(self.tool.drag, Some(Drag::Crop { .. })) { + self.tool.drag = None; + self.commit_named("Crop"); + } + } + pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; self.tool.drag = None; From a9f2598ada1c04ae0555bb67baffa2a1b1206c66 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 10:19:10 +0200 Subject: [PATCH 5/6] End a crop drag when closing another presentation changes the active one close_document activates whichever presentation takes the closed index, so closing a non-active presentation can still change the active one; the crop drag only ended when the active index itself was closed. It now works out which presentation will be active after removal and ends the drag on the original when that differs. document_changed() also drops any leftover Drag::Crop whenever the active presentation changed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 6 +++++- crates/engine/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 7 +++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e02d693e4..09b4b4b92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -573,7 +573,10 @@ impl Session { pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { let before = self.active_uid(); - if self.active == Some(i) { + // The presentation that takes index `i` (or the last one) becomes active, even when + // `i` wasn't: a crop drag ends wherever the active presentation changes. + let next = self.docs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, d)| d.uid).nth(i.min(self.docs.len().saturating_sub(2))); + if next != before { self.end_crop_drag(); } let d = self.docs.remove(i); @@ -592,6 +595,7 @@ impl Session { fn document_changed(&mut self, before: Option) { if self.active_uid() != before { self.tool.crop = None; + self.tool.drop_crop_drag(); } self.settle_crop(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 29a087b0f..88bba9842 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4256,6 +4256,46 @@ fn a_crop_drag_ends_in_its_own_presentation_when_another_becomes_active() { assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.0, 0.0])); } +#[test] +fn a_crop_drag_ends_when_closing_another_presentation_changes_the_active_one() { + let mut s = session(); + let id = cropped_picture(&mut s); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + let uid = |s: &Session| s.doc().unwrap().uid; + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + let a = uid(&s); + let undo_before = s.doc().unwrap().history.undo.len(); + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap(); + // Closing B (index 1) while A is active makes C, which takes index 1, the active one. + s.close_document(1); + assert_ne!(uid(&s), a); + assert!(!s.tool.dragging() && s.tool.crop.is_none()); + let c = s.doc().unwrap().doc.clone(); + s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap(); + let st = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&c, &st.doc) && st.history.undo.is_empty(), "C is untouched"); + s.set_active(0); + let st = s.doc().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(st.history.undo.len(), undo_before + 1); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0])); + // Closing C (index 1) leaves A active: the drag goes on and finishes on release. + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap(); + s.close_document(1); + assert_eq!(uid(&s), a); + assert!(s.tool.dragging()); + s.pointer(ev(PointerKind::Up, 225.0, 175.0)).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 2); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0])); +} + #[test] fn crop_aspect_fill_and_fit() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index d244a634a..795b5f155 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -329,6 +329,13 @@ impl ToolState { }) } + /// Forget a crop drag without finishing it: its presentation is no longer the active one. + pub(crate) fn drop_crop_drag(&mut self) { + if matches!(self.drag, Some(Drag::Crop { .. })) { + self.drag = None; + } + } + /// A freeform path is being drawn (Enter/Escape/double-click finish it). pub fn drawing_freeform(&self) -> bool { matches!(self.drag, Some(Drag::Freeform { .. })) From a05877fa3f269e584f91c257c375b87d57f85332 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 23:47:44 +0200 Subject: [PATCH 6/6] Finish a crop gesture wherever it can be cut short; tighten crop checks - Session::exit_crop() is the one way out of crop mode (Enter/Escape, the toggle, a click elsewhere, a selection that no longer fits): it finishes a crop drag still in progress as its own "Crop" undo step first. - set_tool and Undo/Redo also finish a crop drag first, so later pointer events can't carry it on and Undo takes back exactly that gesture. - Crop drags store crop fractions clamped as the renderer draws them. - picture.crop with keepFrame rejects a crop that leaves nothing showing. - picture.cropMode is enabled only with exactly one picture selected. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/crop.rs | 11 +++++-- crates/engine/src/cmd/edit.rs | 2 ++ crates/engine/src/cmd/shape.rs | 4 +++ crates/engine/src/keys.rs | 2 +- crates/engine/src/tests.rs | 55 ++++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 21 +++++++++---- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/crop.rs b/crates/engine/src/cmd/crop.rs index f86c64e4a..919c01346 100644 --- a/crates/engine/src/cmd/crop.rs +++ b/crates/engine/src/cmd/crop.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { ["Picture Format", "Size", "Crop"], None, "{on?: bool (default: toggle), id?} — show the crop handles on one selected picture: drag them to crop, drag the picture to move it inside, its round handles to scale it; Enter, Escape or a click elsewhere ends", - has_picture, + has_one_picture, crop_mode ), cmd!( @@ -57,6 +57,13 @@ pub fn has_picture(s: &Session) -> std::result::Result<(), String> { if any { Ok(()) } else { Err("select a picture".into()) } } +/// Crop mode works on one picture: with anything else selected too it is off. +pub fn has_one_picture(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let one = s.active().is_some_and(|d| matches!(d.selected_shapes().as_slice(), [sh] if matches!(sh.kind, ShapeKind::Picture { .. }))); + if one { Ok(()) } else { Err("select one picture".into()) } +} + /// The whole image in the box's local points (`w`×`h` box, `crop` = left, top, right, bottom). pub fn image_rect(w: f64, h: f64, crop: [f64; 4]) -> Option { let [l, t, r, b] = crop; @@ -197,7 +204,7 @@ fn crop_target(s: &Session, p: &Value, cmd: &str) -> Result { fn crop_mode(s: &mut Session, p: &Value) -> Result { let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.crop.is_none()); if !on { - s.tool.crop = None; + s.exit_crop(); return Ok(json!({"cropping": Value::Null})); } let id = crop_target(s, p, "picture.cropMode")?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1c2f7da43..4c70f1ec4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -44,6 +44,8 @@ pub fn specs() -> Vec { } fn restore(s: &mut Session, redo: bool) -> Result { + // A crop gesture in progress becomes its own step first, so Undo takes exactly that back. + s.end_crop_drag(); let st = s.doc_mut()?; let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) }; let Some(e) = from.pop() else { return ok() }; diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 8dac09fcd..82fe64876 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -1063,6 +1063,10 @@ fn crop(s: &mut Session, p: &Value) -> Result { *slot = v.clamp(-10.0, 0.95); } } + // Keep at least a sliver showing, as without keepFrame. + if fill.crop[0] + fill.crop[2] > 0.99 || fill.crop[1] + fill.crop[3] > 0.99 { + return Err(bad("picture.crop", "the crop leaves nothing of the picture")); + } } Ok(()) }) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 9db319177..cb523151d 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -59,7 +59,7 @@ impl Session { } self.settle_crop(); if self.tool.crop.is_some() && matches!(k, "enter" | "return" | "escape") { - self.tool.crop = None; + self.exit_crop(); return Ok(json!({"handled": true, "command": "picture.cropMode"})); } if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 88bba9842..510aa8772 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4296,6 +4296,61 @@ fn a_crop_drag_ends_when_closing_another_presentation_changes_the_active_one() { assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0])); } +#[test] +fn every_way_out_of_a_crop_gesture_leaves_it_as_one_undo_step() { + let mut s = session(); + let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let id = cropped_picture(&mut s); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + let undos = |s: &Session| s.doc().unwrap().history.undo.len(); + let ended = |s: &Session| !s.tool.dragging() && s.doc().unwrap().interaction.is_none(); + // Selecting another shape mid-gesture: crop mode ends and the gesture is its own step. + s.execute("picture.cropMode", &json!({"id": id})).unwrap(); + let n = undos(&s); + s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap(); + s.execute("edit.select", &json!({"ids": [rect]})).unwrap(); + assert!(ended(&s) && s.tool.crop.is_none()); + assert_eq!(undos(&s), n + 1); + s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap(); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]), "later events change nothing"); + assert_eq!(undos(&s), n + 1); + // Another tool mid-gesture: the same, and crop mode stays on. + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("picture.cropMode", &json!({})).unwrap(); + s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap(); + s.set_tool(ToolKind::Select); + assert!(ended(&s)); + assert_eq!(undos(&s), n + 2); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0])); + // Undo mid-gesture takes back exactly that gesture; Redo brings it back. + s.pointer(ev(PointerKind::Down, 225.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(ended(&s)); + assert_eq!(undos(&s), n + 2); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0])); + s.pointer(ev(PointerKind::Drag, 150.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 150.0, 175.0)).unwrap(); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0])); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.5, 0.0])); + // Panning the picture off the box stores the crop the renderer draws. + assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _))); + s.pointer(ev(PointerKind::Down, 150.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 550.0, 175.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 550.0, 175.0)).unwrap(); + let c = crop_state(&s, id).1; + assert!(c.iter().all(|v| (-10.0..=0.99).contains(v)) && c[2] == 0.99, "{c:?}"); + // keepFrame can't crop the whole picture away either. + assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6, "keepFrame": true})).is_err()); + // Crop mode needs exactly one picture selected. + s.execute("edit.select", &json!({"ids": [id, rect]})).unwrap(); + assert!(s.execute("picture.cropMode", &json!({})).is_err()); +} + #[test] fn crop_aspect_fill_and_fit() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 795b5f155..6dc6ef31e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -400,13 +400,20 @@ impl Session { /// same picture keeps crop mode on. pub(crate) fn settle_crop(&mut self) { if self.tool.crop.is_some() && self.active().is_none_or(|st| self.tool.cropping(st).is_none()) { - self.tool.crop = None; + self.exit_crop(); } } - /// Finish a crop gesture in the presentation it began in, as releasing the pointer does, - /// before another presentation becomes active or this one closes: one undo step there, and - /// later pointer events can't carry it into another presentation. + /// Leave crop mode. A crop gesture still in progress is finished first, as its own undo step. + /// Every way out of crop mode comes through here. + pub(crate) fn exit_crop(&mut self) { + self.end_crop_drag(); + self.tool.crop = None; + } + + /// Finish a crop gesture in the presentation it began in, as releasing the pointer does: + /// one undo step there, and later pointer events can't carry it on. Called wherever the + /// gesture can be cut short (leaving crop mode, Undo/Redo, another tool, another presentation). pub(crate) fn end_crop_drag(&mut self) { if matches!(self.tool.drag, Some(Drag::Crop { .. })) { self.tool.drag = None; @@ -415,6 +422,7 @@ impl Session { } pub fn set_tool(&mut self, kind: ToolKind) { + self.end_crop_drag(); self.tool.kind = kind; self.tool.drag = None; self.tool.preview = None; @@ -671,7 +679,7 @@ impl Session { self.tool.drag = Some(Drag::Crop { id: cid, part, orig, img, start: p }); return Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})); } - self.tool.crop = None; + self.exit_crop(); } // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; // a click anywhere else leaves Edit Points. @@ -904,7 +912,8 @@ impl Session { (orig, cmd::crop::crop_of(orig.w, orig.h, img + d)) } }; - if let Some(c) = nc { + // Stored as the renderer draws it, so the next gesture starts from what is shown. + if let Some(c) = nc.map(|c| c.map(|v| v.clamp(-10.0, 0.99))) { cmd::crop::store(self, "crop", &[(id, Some(nx), c)])?; } }