Browse / github.com/storytold/deckcraft / patch
Crop pictures like PowerPoint: crop mode with crop handles, move and scale the picture inside
Crop pictures like PowerPoint: crop mode with crop handles, move and scale the picture inside Picture Format › Crop had no crop mode: only fixed "10% / 20% each side" presets, and `picture.crop` changed the crop fractions while keeping the box, so the visible part stretched into it. This adds cropping the way PowerPoint does it. **Crop mode** (`picture.cropMode`, the top half of the now split Crop button, and Crop in the picture's context menu): - Black crop marks on the box: dragging them moves the box's edges over the picture, which keeps its size and place. - The rest of the picture shows faded outside the box, with round handles at its edges. Dragging the picture moves it under t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- kinower (imported from pull request #1069, open)
- Base
ee875abffa1a31cb760c843bb29e79391a90a766- Size
- +1194 −18 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
22f6a2a3a1b6b41a6b64a282
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/src/cmd/crop.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/shape.rs crates/engine/src/cmd/shape/picture.rs crates/engine/src/keys.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/engine/src/tools.rs crates/engine/src/tools/click.rs crates/engine/src/tools/drag.rs crates/engine/src/tools/geometry.rs crates/engine/src/tools/pointer.rs crates/ui-egui/src/canvas/context_menu.rs crates/ui-egui/src/canvas/input.rs crates/ui-egui/src/canvas/overlays.rs crates/ui-egui/src/ribbon/picture_format.rs
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-26 | Picture crop handles and image drags snap to slide edges and centres, visible drawing guides, other shapes and the enabled grid, using the same rules as shape resizing; Ctrl/Cmd bypasses snapping and rotated pictures keep free crop gestures (#1069) | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -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());new file mode 100644@@ -0,0 +1,374 @@+//! 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<CommandSpec> {+ 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_one_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()) }+}++/// 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<Rect> {+ 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<ShapeId> {+ 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<Value> {+ let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.crop.is_none());+ if !on {+ s.exit_crop();+ 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<Vec<(ShapeId, Xfrm, [f64; 4])>> {+ 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<Xfrm>, [f64; 4])]) -> Result<Value> {+ 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<f64>; 4]) -> Result<Value> {+ 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<f64> {+ let r = match v {+ Value::String(t) => {+ let (a, b) = t.split_once([':', '/', 'x'])?;+ a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().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<Value> {+ 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<Value> {+ 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());+ }+}@@ -47,6 +47,8 @@ pub fn specs() -> Vec<CommandSpec> { } fn restore(s: &mut Session, redo: bool) -> Result<Value> {+ // A crop gesture in progress becomes its own step first, so Undo takes exactly that back.+ s.end_crop_drag(); let vertex_kind = s.tool.vertex_kind.clone(); 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) };@@ -58,6 +60,7 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> { // A later shape can get an undone copy's id: Duplicate starts over with the usual offset. st.last_duplicate = None; st.revision += 1;+ s.settle_crop(); // The point type Edit Points remembered with the restored document: handle drags follow it. s.tool.vertex_kind = e.vertex_kind; Ok(json!({"label": e.label}))@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 ),@@ -2,6 +2,9 @@ use super::*; pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> { 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 crate::cmd::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() {@@ -9,6 +12,10 @@ pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> { *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(()) })@@ -60,6 +60,11 @@ 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.exit_crop();+ 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;@@ -584,25 +584,40 @@ impl Session { pub fn set_active(&mut self, i: usize) { let was = self.active().map(|d| d.uid); 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); } self.forget_vertex_kind_unless(was); } pub fn add_document(&mut self, d: DocState) -> usize {- let was = self.active().map(|d| d.uid);+ let before = self.active_uid();+ self.end_crop_drag(); self.docs.push(d); self.active = Some(self.docs.len() - 1);- self.forget_vertex_kind_unless(was);+ self.document_changed(before);+ self.forget_vertex_kind_unless(before); self.docs.len() - 1 } pub fn close_document(&mut self, i: usize) { let was = self.active().map(|d| d.uid); if i < self.docs.len() {+ let before = self.active_uid();+ // 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); 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); } self.forget_vertex_kind_unless(was); }@@ -613,6 +628,18 @@ impl Session { self.tool.vertex_kind = None; } }+ fn active_uid(&self) -> Option<u64> {+ 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<u64>) {+ if self.active_uid() != before {+ self.tool.crop = None;+ self.tool.drop_crop_drag();+ }+ self.settle_crop();+ } pub fn next_untitled(&mut self) -> String { self.untitled += 1; format!("Presentation{}", self.untitled)@@ -667,6 +694,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)@@ -754,6 +782,7 @@ impl Session { if st.composition.is_some() && owner(&st.selection) != before { self.settle_composition(); }+ self.settle_crop(); Ok(()) } }@@ -5462,6 +5462,419 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { 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_frame_snaps_only_dragged_edges_and_keeps_the_image_in_place() {+ for target in ["guide", "slide", "center", "shape", "grid", "hidden", "disabled", "ctrl", "self"] {+ let mut s = session();+ let id = cropped_picture(&mut s);+ s.prefs.smart_guides = target != "disabled";+ s.tool.guides_visible = target != "hidden";+ s.edit(|doc, _| {+ doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 240.0 });+ Ok(())+ })+ .unwrap();+ if target == "shape" {+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [240, 310, 40, 40]})).unwrap();+ }+ if target == "grid" {+ s.tool.guides_visible = false;+ s.prefs.snap_to_grid = true;+ s.prefs.grid_spacing = 20.0;+ }+ let (at, expected) = match target {+ "slide" => (s.doc().unwrap().doc.slide_size.width - 2.0, s.doc().unwrap().doc.slide_size.width),+ "center" => (s.doc().unwrap().doc.slide_size.width / 2.0 - 2.0, s.doc().unwrap().doc.slide_size.width / 2.0),+ "hidden" | "disabled" | "ctrl" => (238.0, 238.0),+ "self" => (298.0, 298.0),+ _ => (238.0, 240.0),+ };+ let mods = Mods { cmd: target == "ctrl", ..Mods::default() };+ let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };+ s.execute("edit.select", &json!({"ids": [id]})).unwrap();+ s.execute("picture.cropMode", &json!({"id": id})).unwrap();+ s.pointer(ev(PointerKind::Down, 300.0)).unwrap();+ s.pointer(ev(PointerKind::Drag, at)).unwrap();+ let (b, c) = crop_state(&s, id);+ assert!(near(b, [100.0, 100.0, expected - 100.0, 150.0]), "{target}: {b:?}");+ let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+ assert!(near([image.x0 + b[0], image.y0 + b[1], image.width(), image.height()], [100.0, 100.0, 200.0, 150.0]), "{target}: {image:?}");+ if matches!(target, "guide" | "slide" | "center" | "shape") {+ assert!(s.tool.guides.iter().any(|g| g.vertical && (g.pos - expected).abs() < 1e-6), "{target}");+ } else {+ assert!(s.tool.guides.is_empty(), "{target}");+ }+ s.pointer(ev(PointerKind::Up, at)).unwrap();+ s.execute("edit.undo", &json!({})).unwrap();+ assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));+ }+}++#[test]+fn crop_image_handles_and_pan_snap_without_moving_the_frame() {+ for pan in [false, true] {+ for ctrl in [false, true] {+ let mut s = session();+ let id = cropped_picture(&mut s);+ s.execute("picture.crop", &json!({"ids": [id], "right": 0.25})).unwrap();+ s.prefs.smart_guides = true;+ s.tool.guides_visible = true;+ s.edit(|doc, _| {+ doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 340.0 });+ Ok(())+ })+ .unwrap();+ s.execute("picture.cropMode", &json!({"id": id})).unwrap();+ let mods = Mods { cmd: ctrl, ..Mods::default() };+ let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };+ let start = if pan { 200.0 } else { 300.0 };+ let hit = s.pointer(ev(PointerKind::Down, start)).unwrap();+ assert_eq!(hit["hit"]["kind"], if pan { "cropPan" } else { "cropImage" });+ s.pointer(ev(PointerKind::Drag, start + 38.0)).unwrap();+ let (b, c) = crop_state(&s, id);+ assert!(near(b, [100.0, 100.0, 150.0, 150.0]));+ let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+ assert!((image.x1 + b[0] - if ctrl { 338.0 } else { 340.0 }).abs() < 1e-6, "{image:?}");+ assert_eq!(s.tool.guides.is_empty(), ctrl);+ }+ }+}++#[test]+fn crop_snapping_handles_flips_and_skips_rotated_pictures() {+ for rotated in [false, true] {+ let mut s = session();+ let id = cropped_picture(&mut s);+ let sid = ShapeId(id as _);+ s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap();+ if rotated {+ s.execute("shape.rotate", &json!({"ids": [id], "by": 30.0})).unwrap();+ }+ let orig = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();+ let start = orig.affine() * deckcraft_geom::Point::new(200.0, 75.0);+ let to = orig.affine() * deckcraft_geom::Point::new(138.0, 75.0);+ let guide = to.x + 2.0;+ s.prefs.smart_guides = true;+ s.tool.guides_visible = true;+ s.edit(|doc, _| {+ doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: guide });+ Ok(())+ })+ .unwrap();+ s.execute("picture.cropMode", &json!({"id": id})).unwrap();+ let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 3.0, pressure: None };+ s.pointer(ev(PointerKind::Down, start)).unwrap();+ s.pointer(ev(PointerKind::Drag, to)).unwrap();+ let (b, c) = crop_state(&s, id);+ assert!((b[2] - if rotated { 138.0 } else { 136.0 }).abs() < 1e-6, "{b:?}");+ assert_eq!(s.tool.guides.is_empty(), rotated);+ let x = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();+ let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+ let image_origin = x.affine() * image.origin();+ let before = orig.affine() * deckcraft_geom::Point::ORIGIN;+ assert!((image_origin - before).hypot() < 1e-6);+ assert!((image.width() - 200.0).abs() < 1e-6);+ }+}++#[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_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 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 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 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();+ 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());+}+ /// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off. /// The first box is selected. fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) {@@ -136,6 +136,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)]@@ -223,6 +245,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 {@@ -292,6 +322,8 @@ pub struct ToolState { pub guide_hover: Option<usize>, /// Pressure from the pointer event being handled, if the device reported one. pressure: Option<f32>,+ /// The picture in crop mode (Picture Format ▸ Crop): its crop handles replace the box's.+ pub crop: Option<ShapeId>, } impl ToolState {@@ -309,6 +341,22 @@ impl ToolState { }) } + /// The picture in crop mode, while it is still the one selected shape.+ pub fn cropping(&self, st: &crate::DocState) -> Option<ShapeId> {+ 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)+ })+ }++ /// 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 { .. }))@@ -91,7 +91,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, };@@ -63,6 +63,41 @@ 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 f = self.snap_crop_rect(f, &orig, Some(h), id, tol, !mods.cmd);+ 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) => {+ let image = cmd::crop::drag_image(img, h, d, mods.shift);+ let image = self.snap_crop_rect(image, &orig, Some(h), id, tol, !mods.cmd);+ (orig, cmd::crop::crop_of(orig.w, orig.h, image))+ }+ CropPart::Pan => {+ if mods.shift {+ if d.x.abs() > d.y.abs() {+ d.y = 0.0;+ } else {+ d.x = 0.0;+ }+ }+ let image = self.snap_crop_rect(img + d, &orig, None, id, tol, !mods.cmd);+ (orig, cmd::crop::crop_of(orig.w, orig.h, image))+ }+ };+ // 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)])?;+ }+ } 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);@@ -227,6 +227,29 @@ impl Session { (off, guides) } + /// Snap a crop rectangle in slide coordinates, then return it to the original picture's+ /// local frame. Exclude the picture itself: its fixed crop box is not a snap target.+ pub(super) fn snap_crop_rect(&mut self, r: Rect, orig: &Xfrm, handle: Option<u8>, id: ShapeId, tol: f64, enabled: bool) -> Rect {+ if !enabled || orig.rot != 0.0 {+ self.tool.guides.clear();+ return r;+ }+ let x = cmd::crop::frame_xfrm(orig, r);+ let snapped = if let Some(h) = handle {+ self.snap_resize(x, orig, h, id, tol).rect()+ } else {+ let (off, guides) = self.snap_rect(x.rect(), &[id], tol * 1.5, true);+ self.tool.guides = guides;+ x.rect() + off+ };+ let local = orig.affine().inverse().transform_rect_bbox(snapped);+ if local.width() < cmd::crop::MIN_SIDE || local.height() < cmd::crop::MIN_SIDE {+ self.tool.guides.clear();+ return r;+ }+ local+ }+ /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { if x.rot != 0.0 {@@ -2,7 +2,34 @@ use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_t use super::*; 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.exit_crop();+ }+ }++ /// 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;+ self.commit_named("Crop");+ }+ }+ pub fn set_tool(&mut self, kind: ToolKind) {+ self.end_crop_drag(); self.tool.kind = kind; self.tool.drag = None; self.tool.preview = None;@@ -12,6 +39,30 @@ impl Session { /// What's under `p` on the current slide. pub fn hit_test(&self, p: Point, tol: f64) -> Option<Hit> { 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)@@ -103,6 +154,7 @@ impl Session { if !matches!(ev.kind, PointerKind::Move) { self.settle_composition(); }+ self.settle_crop(); match ev.kind { PointerKind::Move => { if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {@@ -265,6 +317,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.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. if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) {@@ -316,8 +392,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");@@ -218,6 +218,10 @@ pub(super) 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();@@ -143,6 +143,13 @@ pub(super) 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::CropPan { .. })) => egui::CursorIcon::Move,+ (_, Some(Hit::Crop { handle, .. } | Hit::CropImage { handle, .. })) => match handle {+ 0 | 4 => egui::CursorIcon::ResizeNwSe,+ 2 | 6 => egui::CursorIcon::ResizeNeSw,+ 1 | 5 => egui::CursorIcon::ResizeVertical,+ _ => egui::CursorIcon::ResizeHorizontal,+ }, (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle), (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,@@ -1,5 +1,113 @@ use super::*; +/// 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<egui::TextureHandle> {+ const SIDE: u32 = 1024;+ let item = doc.media(media)?;+ let key = egui::Id::unique(("crop-ghost", std::sync::Arc::as_ptr(&item.data) as usize, item.data.len()));+ if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(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);@@ -37,6 +145,12 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: 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.@@ -152,19 +152,51 @@ pub(super) 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); }