From 9397d2ca0300bb1672eecc658d32f78ff2d8aafd Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:16:26 +0300 Subject: [PATCH 1/6] M1: crop image fills during Command/Ctrl resize (#702) --- ROADMAP.md | 4 +- contributors/people.toml | 3 + crates/engine/src/tools.rs | 33 ++++ .../engine/src/transform_behaviour_tests.rs | 181 ++++++++++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 223 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..82d48ca2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Command/Ctrl-resize with the Move tool crops image fills on a single non-text layer outside auto layout, keeping their scale and position and the original image bytes. Fill/Fit/Crop, rotated layers and image fills, undo and native save/reopen are covered by regression tests. Text, auto-layout contexts, Tile and the Scale tool keep their existing behaviour. Estimates unchanged. + - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index bc8cf71b..1d6cd8b7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,6 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" + +[people.timrrrr] +display_name = "Timur" diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3d3b9acb..fea703b9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; +use figcraft_color::{ImageScaleMode, PaintKind}; use figcraft_doc::geometry::select_target; use figcraft_doc::*; use figcraft_geom::{Affine, Point, Rect, Vec2}; @@ -1558,6 +1559,38 @@ impl Session { if flip_y { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } + // Text reflow and auto layout can recompute this box after the preview. + let crop_images = ev.cmd + && !matches!(doc.get(*id)?.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let n = doc.get(*id)?; + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + img.crop = Some((Affine::new(crop) * to_old).as_coeffs()); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } doc.set_world_transform(*id, t)?; if kept.is_empty() { crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 47270a42..250b7da1 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -98,6 +98,187 @@ const ALT: Mods = Mods { shift: false, alt: true, cmd: false }; const BOTH: Mods = Mods { shift: true, alt: true, cmd: false }; const CMD: Mods = Mods { shift: false, alt: false, cmd: true }; +/// An asymmetric synthetic image: every column is different, so recentering is visible. +fn image_layer(s: &mut Session) -> NodeId { + use base64::Engine as _; + let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + id(&ex( + s, + "file.import", + json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()), "name": "crop.png", "x": 0, "y": 0}), + )) +} + +#[test] +fn command_resize_crops_image_pixels_and_round_trips() { + let mut s = session(); + let layer = image_layer(&mut s); + let original = s.doc().unwrap().clone(); + let before = figcraft_render::render_node(&original, layer, 1.0).unwrap(); + let steps = undo_steps(&s); + // Public crop documentation: Command (macOS) / Ctrl (elsewhere) quickly crops an image. + // Moving the right edge must keep the left half's pixels, not recenter or scale them. + s.pointer(&ev(PointerKind::Down, Point::new(200.0, 50.0), CMD)).unwrap(); + for x in [150.0, 100.0] { + s.pointer(&ev(PointerKind::Move, Point::new(x, 50.0), CMD)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, Point::new(100.0, 50.0), CMD)).unwrap(); + assert_eq!(size(&s, layer), (100.0, 100.0)); + let after = figcraft_render::render_node(s.doc().unwrap(), layer, 1.0).unwrap(); + for y in 0..100usize { + assert_eq!(&after.data()[y * 400..(y + 1) * 400], &before.data()[y * 800..y * 800 + 400], "row {y}: the image stays put"); + } + assert_eq!(s.doc().unwrap().images, original.images, "cropping keeps the source bytes"); + assert_eq!(undo_steps(&s), steps + 1); + let cropped = s.doc().unwrap().clone(); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap(), &original); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc().unwrap(), &cropped); + let path = std::env::temp_dir().join(format!("figcraft-command-crop-{}.figcraft", std::process::id())); + ex(&mut s, "file.saveAs", json!({"path": path})); + let mut reopened = session(); + ex(&mut reopened, "file.open", json!({"path": path})); + assert_eq!(reopened.doc().unwrap().node(layer), cropped.node(layer)); + assert_eq!(reopened.doc().unwrap().images, cropped.images); + assert_eq!(figcraft_render::render_node(reopened.doc().unwrap(), layer, 1.0).unwrap().data(), after.data()); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn command_crop_keeps_image_placement_in_the_layers_axes() { + use figcraft_doc::color::{ImageScaleMode, PaintKind}; + use figcraft_geom::Affine; + // Each row exercises a distinct placement: Fill, Fit, and an existing affine Crop. + // Geometry follows the existing resize rules; image pixels stay fixed in page space. + let rows = [ + ("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(40.0, 50.0), CMD, (40.0, 0.0, 160.0, 150.0)), + ("fit", None, 30.0, Point::new(100.0, 0.0), Point::new(100.0, 20.0), CMD, (0.0, 20.0, 200.0, 130.0)), + ("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(240.0, 50.0), CMD, (240.0, 0.0, 40.0, 150.0)), + ( + "crop", + Some([0.8, 0.1, -0.2, 0.9, 0.1, 0.05]), + 30.0, + Point::new(200.0, 150.0), + Point::new(150.0, 112.5), + Mods { alt: true, ..CMD }, + (50.0, 37.5, 100.0, 75.0), + ), + ]; + for (mode, crop, angle, from, to, mods, (x, y, w, h)) in rows { + let mut s = session(); + let layer = image_layer(&mut s); + let mut fills = s.doc().unwrap().node(layer).unwrap().fills.clone(); + let PaintKind::Image(img) = &mut fills[0].kind else { panic!("image fill") }; + img.scale_mode = match mode { + "fit" => ImageScaleMode::Fit, + "crop" => ImageScaleMode::Crop, + _ => ImageScaleMode::Fill, + }; + img.crop = crop; + set(&mut s, &[layer], json!({"height": 150, "rotation": angle, "fills": fills})); + let doc = s.doc().unwrap(); + let world = doc.world_transform(layer); + let n = doc.node(layer).unwrap(); + let old_crop = crop.unwrap_or(if mode == "fit" { [1.0, 0.0, 0.0, 1.5, 0.0, -0.25] } else { [2.0 / 3.0, 0.0, 0.0, 1.0, 1.0 / 6.0, 0.0] }); + let old_mapping = Affine::new(old_crop) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * world.inverse(); + drag(&mut s, world * from, world * to, mods); + let doc = s.doc().unwrap(); + let n = doc.node(layer).unwrap(); + assert!(near(n.width, w) && near(n.height, h), "{mode}: crop box size"); + assert!((doc.world_transform(layer) * Point::ZERO - world * Point::new(x, y)).hypot() < 1e-6, "{mode}: crop box anchor"); + let PaintKind::Image(img) = &n.fills[0].kind else { panic!("image fill") }; + assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode}"); + let mapping = + Affine::new(img.crop.unwrap()) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * doc.world_transform(layer).inverse(); + for p in [Point::ZERO, Point::new(80.0, 60.0), Point::new(170.0, 90.0)] { + assert!((mapping * p - old_mapping * p).hypot() < 1e-6, "{mode}: same page point samples the same source pixel"); + } + } +} + +#[test] +fn ordinary_resize_and_scale_keep_image_fill_settings() { + for (tool, cmd, ctrl) in [("move", false, false), ("move", false, true), ("scale", true, false)] { + let mut s = session(); + let layer = image_layer(&mut s); + let fills = s.doc().unwrap().node(layer).unwrap().fills.clone(); + ex(&mut s, "tool.set", json!({"tool": tool})); + let to = Point::new(100.0, 50.0); + for (kind, p) in [(PointerKind::Down, Point::new(200.0, 100.0)), (PointerKind::Move, to), (PointerKind::Up, to)] { + let mut event = ev(kind, p, Mods { cmd, ..NONE }); + event.ctrl = ctrl; + s.pointer(&event).unwrap(); + } + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{tool}, cmd={cmd}, ctrl={ctrl}"); + assert_eq!(size(&s, layer), (100.0, 50.0)); + } +} + +#[test] +fn command_crop_preserves_each_image_fill_and_leaves_tiles() { + use figcraft_doc::color::{Color, ImageScaleMode, Paint, PaintKind}; + let mut s = session(); + let layer = image_layer(&mut s); + let image = s.doc().unwrap().node(layer).unwrap().fills[0].clone(); + let mut rotated = image.clone(); + rotated.opacity = 0.4; + let PaintKind::Image(img) = &mut rotated.kind else { panic!("image fill") }; + img.rotation = 1; + let mut tile = image.clone(); + let PaintKind::Image(img) = &mut tile.kind else { panic!("image fill") }; + img.scale_mode = ImageScaleMode::Tile; + let mut expected = vec![Paint::solid(Color::from_rgba8(20, 40, 60, 255)), rotated, image, tile]; + set(&mut s, &[layer], json!({"fills": expected})); + drag(&mut s, Point::new(200.0, 50.0), Point::new(100.0, 50.0), CMD); + for (i, crop) in [(1, [0.5, 0.0, 0.0, 0.25, 0.0, 0.375]), (2, [0.5, 0.0, 0.0, 1.0, 0.0, 0.0])] { + let PaintKind::Image(img) = &mut expected[i].kind else { panic!("image fill") }; + img.scale_mode = ImageScaleMode::Crop; + img.crop = Some(crop); + } + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, expected); +} + +#[test] +fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() { + for case in ["text", "child", "frame"] { + let mut s = session(); + let image = image_layer(&mut s); + let fills = s.doc().unwrap().node(image).unwrap().fills.clone(); + let layer = match case { + "text" => id(&ex( + &mut s, + "text.create", + json!({"text": "hello world hello world hello world", "props": { + "width": 200, "height": 100, "fontSize": 24, "textAutoResize": "none", "fills": fills + }}), + )), + _ => { + let frame = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))); + set(&mut s, &[frame], json!({"layout": "horizontal", "padding": 20})); + if case == "child" { + ex(&mut s, "node.reparent", json!({"ids": [image], "parent": frame})); + set(&mut s, &[image], json!({"minWidth": 150})); + image + } else { + set(&mut s, &[frame], json!({"fills": fills})); + frame + } + } + }; + ex(&mut s, "select.set", json!({"ids": [layer]})); + let doc = s.doc().unwrap(); + let world = doc.world_transform(layer); + let n = doc.node(layer).unwrap(); + let from = world * Point::new(n.width, n.height / 2.0); + let to = world * Point::new(10.0, n.height / 2.0); + drag(&mut s, from, to, CMD); + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{case}: keep the existing resize behaviour"); + } +} + #[test] fn handle_drags_resize_by_the_documented_rules() { // A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..faeb7430 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,6 +88,7 @@ interaction feel not yet compared. | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | +| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | ## Canvas @@ -166,6 +167,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Command/Ctrl-resize crops image fills without moving or scaling their source pixels; one undo step, saved with the document. Single non-text layers outside auto layout; Tile excluded. Estimates unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From 8a77ef6759158f97d1768a367726f50541adccf9 Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:18:07 +0300 Subject: [PATCH 2/6] M1: preserve image fills with variable-bound resize dimensions --- ROADMAP.md | 3 +- crates/engine/src/tools.rs | 7 ++-- .../engine/src/transform_behaviour_tests.rs | 39 +++++++++++++++++++ docs/ui-parity.md | 5 ++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82d48ca2..f9754a2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variable-bound sizes retain existing resize behaviour during Command/Ctrl image crop; Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Command/Ctrl image crop excludes variable-bound width/height (including `w`/`h` aliases), preserving the existing resize behaviour when normalization restores the bound size. Other bindings still allow crop; pixel and binding regressions cover both dimensions and ordinary resize. Estimates unchanged. - **2026-10-16** · Command/Ctrl-resize with the Move tool crops image fills on a single non-text layer outside auto layout, keeping their scale and position and the original image bytes. Fill/Fit/Crop, rotated layers and image fills, undo and native save/reopen are covered by regression tests. Text, auto-layout contexts, Tile and the Scale tool keep their existing behaviour. Estimates unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index fea703b9..fe502581 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1559,14 +1559,15 @@ impl Session { if flip_y { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } - // Text reflow and auto layout can recompute this box after the preview. + // Text reflow, size bindings and auto layout can recompute this box after the preview. + let n = doc.get(*id)?; let crop_images = ev.cmd - && !matches!(doc.get(*id)?.kind, NodeKind::Text(_)) + && !matches!(n.kind, NodeKind::Text(_)) + && !n.bound.keys().any(|field| matches!(field.as_str(), "width" | "height" | "w" | "h")) && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); if crop_images { // Crop the layer's image fills without moving their pixels on the page. // Work from the interaction snapshot, just like the geometry on every move. - let n = doc.get(*id)?; let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); let to_old = Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 250b7da1..146bdfd6 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -279,6 +279,45 @@ fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() { } } +#[test] +fn image_crop_respects_variable_bound_dimensions() { + for (field, value, from, to) in [ + ("width", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ("height", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), + ("w", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ("h", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), + ("opacity", 100, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ] { + for mods in [CMD, NONE] { + let mut s = session(); + let layer = image_layer(&mut s); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens"})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "bound", "type": "number", "value": value})); + ex(&mut s, "variable.bind", json!({"ids": [layer], "field": field, "variable": "bound"})); + let before = s.doc().unwrap().clone(); + let pixels = figcraft_render::render_node(&before, layer, 1.0).unwrap(); + drag(&mut s, from, to, mods); + let doc = s.doc().unwrap(); + let n = doc.node(layer).unwrap(); + assert_eq!(n.bound, before.node(layer).unwrap().bound, "{field}: keep the binding"); + assert_eq!(doc.variables, before.variables, "{field}: do not edit the variable"); + let after = figcraft_render::render_node(doc, layer, 1.0).unwrap(); + if field != "opacity" { + assert_eq!((n.width, n.height), (200.0, 100.0), "{field}: the binding restores the size"); + assert!(after.data() == pixels.data(), "{field}, cmd={}: restored bounds must not stretch a partial crop", mods.cmd); + } else if mods.cmd { + assert_eq!((n.width, n.height), (100.0, 100.0)); + for y in 0..100usize { + assert_eq!(&after.data()[y * 400..(y + 1) * 400], &pixels.data()[y * 800..y * 800 + 400], "opacity binding still allows crop"); + } + } + if field != "opacity" || !mods.cmd { + assert_eq!(n.fills, before.node(layer).unwrap().fills, "{field}: keep the existing resize behaviour"); + } + } + } +} + #[test] fn handle_drags_resize_by_the_documented_rules() { // A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index faeb7430..4eff3aac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variable-bound sizes retain existing resize behaviour during Command/Ctrl image crop; Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,7 +88,7 @@ interaction feel not yet compared. | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | -| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | +| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, variable-bound width/height, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | ## Canvas @@ -167,6 +167,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Command/Ctrl image crop leaves fills unchanged when width or height has a variable binding (including `w`/`h` aliases); normalization may restore those dimensions after preview. Other bindings still allow crop. Regression covers pixels, binding preservation and ordinary resize. Estimates unchanged. | | 2026-10-16 | minor | Command/Ctrl-resize crops image fills without moving or scaling their source pixels; one undo step, saved with the document. Single non-text layers outside auto layout; Tile excluded. Estimates unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From e409f9efc0c5ef98b430e4e66263021478bac046 Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:48:50 +0300 Subject: [PATCH 3/6] M1: retain GitHub username for contributor credit --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 1d6cd8b7..bc8cf71b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,6 +44,3 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" - -[people.timrrrr] -display_name = "Timur" From 86e39cc9c053a8746ee26cc6f3199b3d8c410ea5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:04:32 +0700 Subject: [PATCH 4/6] #703: a Cmd/Ctrl-resize crop whose coefficients aren't finite leaves the image fill as it is --- crates/engine/src/tools.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ca078eb5..493df125 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1711,7 +1711,12 @@ impl Session { let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] }; - img.crop = Some((Affine::new(crop) * to_old).as_coeffs()); + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); img.scale_mode = ImageScaleMode::Crop; } doc.get_mut(*id)?.fills = fills; From caeab4e89e519cea6b5c905f363db384e1a04065 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:09:27 +0700 Subject: [PATCH 5/6] #703: an explicit size edit detaches a binding made under the short names w and h too, as it does width and height --- crates/engine/src/cmd/styles.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index dd93bac3..cb85ed5a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -1010,8 +1010,8 @@ fn bound_field(n: &Node, field: &str) -> Option { let cl = &n.child_layout; let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied()); Some(match field { - "width" => format!("{}", n.width), - "height" => format!("{}", n.height), + "width" | "w" => format!("{}", n.width), + "height" | "h" => format!("{}", n.height), "minWidth" => format!("{:?}", cl.min_w), "maxWidth" => format!("{:?}", cl.max_w), "minHeight" => format!("{:?}", cl.min_h), From a44bba56a07ab565c142cd1a37c39bc88c81bc3f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:09:28 +0700 Subject: [PATCH 6/6] #703: Cmd/Ctrl-resize crops a layer whose size was bound to a variable as well: the resize detaches the binding (#809), so the new size sticks --- crates/engine/src/tools.rs | 4 +-- .../engine/src/transform_behaviour_tests.rs | 34 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 493df125..ac60a758 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1683,11 +1683,11 @@ impl Session { if flip_y { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } - // Text reflow, size bindings and auto layout can recompute this box after the preview. + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). let n = doc.get(*id)?; let crop_images = ev.cmd && !matches!(n.kind, NodeKind::Text(_)) - && !n.bound.keys().any(|field| matches!(field.as_str(), "width" | "height" | "w" | "h")) && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); if crop_images { // Crop the layer's image fills without moving their pixels on the page. diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 146bdfd6..3e724244 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -279,14 +279,16 @@ fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() { } } +/// A resize is an explicit edit of the size: a size bound to a variable lets go of it (#809), so +/// Cmd/Ctrl crops a bound layer's image as any other's; other bindings stay. #[test] -fn image_crop_respects_variable_bound_dimensions() { - for (field, value, from, to) in [ - ("width", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), - ("height", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), - ("w", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), - ("h", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), - ("opacity", 100, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), +fn image_crop_detaches_a_bound_size_and_crops() { + for (field, value, from, to, (w, h)) in [ + ("width", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100usize, 100usize)), + ("height", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0), (200, 50)), + ("w", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100, 100)), + ("h", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0), (200, 50)), + ("opacity", 100, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100, 100)), ] { for mods in [CMD, NONE] { let mut s = session(); @@ -299,19 +301,15 @@ fn image_crop_respects_variable_bound_dimensions() { drag(&mut s, from, to, mods); let doc = s.doc().unwrap(); let n = doc.node(layer).unwrap(); - assert_eq!(n.bound, before.node(layer).unwrap().bound, "{field}: keep the binding"); + assert_eq!(n.bound.is_empty(), field != "opacity", "{field}: a size binding goes, others stay"); assert_eq!(doc.variables, before.variables, "{field}: do not edit the variable"); - let after = figcraft_render::render_node(doc, layer, 1.0).unwrap(); - if field != "opacity" { - assert_eq!((n.width, n.height), (200.0, 100.0), "{field}: the binding restores the size"); - assert!(after.data() == pixels.data(), "{field}, cmd={}: restored bounds must not stretch a partial crop", mods.cmd); - } else if mods.cmd { - assert_eq!((n.width, n.height), (100.0, 100.0)); - for y in 0..100usize { - assert_eq!(&after.data()[y * 400..(y + 1) * 400], &pixels.data()[y * 800..y * 800 + 400], "opacity binding still allows crop"); + assert_eq!((n.width, n.height), (w as f64, h as f64), "{field}"); + if mods.cmd { + let after = figcraft_render::render_node(doc, layer, 1.0).unwrap(); + for y in 0..h { + assert_eq!(&after.data()[y * w * 4..(y + 1) * w * 4], &pixels.data()[y * 800..y * 800 + w * 4], "{field}: the image stays put"); } - } - if field != "opacity" || !mods.cmd { + } else { assert_eq!(n.fills, before.node(layer).unwrap().fills, "{field}: keep the existing resize behaviour"); } }