From a0ec548cd7e2b8ecee07551f80ee0f2518445516 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 15:19:39 +0500 Subject: [PATCH 01/14] Crop: the overlay shows Always by default, and Fill resets when another open document becomes active --- crates/ui-egui/src/app/commands.rs | 10 ++ crates/ui-egui/src/state/tool_options.rs | 3 +- crates/ui-egui/src/tools/crop_overlay.rs | 10 +- crates/ui-egui/tests/it/crop_defaults.rs | 137 +++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/control-protocol.md | 2 +- 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/crop_defaults.rs diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 1a43ffe77f3..94222d9343e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -295,6 +295,16 @@ impl PhotocraftApp { // snapshot and advance its revision; a replaced snapshot without a newer revision is a // reopened document, even with the same ID. let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc))); + // Crop's Fill goes back to its default entry when another document that was already open + // becomes active (a tab switch, or closing the active one), as in the reference + // application. A new or opened document isn't in `view_docs` yet, so it keeps the setting, + // as do tool switches, commits and cancels, which leave the active document alone (#10894). + if let (Some((id, ..)), Some((old_id, ..))) = (&document, &self.path_document) + && id != old_id + && self.view_docs.contains(id) + { + self.ui.tool_options.crop_content_aware = false; + } let same_document = match (&self.path_document, &document) { (Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)), (None, None) => true, diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index bf5303e0d52..089da7ab6b5 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -184,7 +184,8 @@ pub struct ToolOptions { #[serde(default = "yes")] pub perspective_crop_grid: bool, /// Crop overlay (#1919): the guide in the crop box, when it shows and its orientation - /// (Photoshop's defaults: Rule of Thirds, Auto Show Overlay). See `crop_overlay`. + /// (the reference application's defaults: Rule of Thirds, Always Show Overlay, #10894). See + /// `crop_overlay`. #[serde(default)] pub crop_overlay: crate::crop_overlay::CropOverlay, #[serde(default)] diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index c93f5e12e10..0d12f0a949c 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -81,8 +81,10 @@ impl CropOverlay { #[serde(rename_all = "camelCase")] pub enum OverlayShow { /// Only while the crop box is being dragged (drawn, moved, resized or turned). - #[default] Auto, + /// The whole time a crop is pending, as the reference application starts out (its Reset Tool + /// brings back Always Show Overlay, #10894). + #[default] Always, Never, } @@ -438,7 +440,7 @@ mod tests { #[test] fn cycling_overlays_and_orientations() { let mut o = ToolOptions::default(); - assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0)); + assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0)); let mut seen = vec![o.crop_overlay]; for _ in 0..6 { cycle(&mut o); @@ -539,6 +541,8 @@ mod tests { #[test] fn the_overlay_shows_by_mode_and_gesture() { let mut app = crop_app(); + // Auto is no longer the default (#10894): set it for the Auto half of this test. + app.ui.tool_options.crop_overlay_show = OverlayShow::Auto; assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Auto: no guides without a drag"); app.crop.drag = Some(crate::crop_ui::CropDrag::Move { start: [0.0, 0.0], rect: [0.0, 0.0, 10.0, 10.0] }); assert_eq!(current_guides(&app, 300.0, 200.0).len(), 4, "Auto: the thirds while dragging"); @@ -555,7 +559,7 @@ mod tests { #[test] fn old_state_files_get_photoshops_defaults() { let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); - assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0)); + assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0)); let o: ToolOptions = serde_json::from_str(r#"{"crop_overlay": "goldenSpiral", "crop_overlay_show": "always", "crop_overlay_orientation": 3}"#).unwrap(); assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::GoldenSpiral, OverlayShow::Always, 3)); } diff --git a/crates/ui-egui/tests/it/crop_defaults.rs b/crates/ui-egui/tests/it/crop_defaults.rs new file mode 100644 index 00000000000..d26acd21c84 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_defaults.rs @@ -0,0 +1,137 @@ +//! #10894: two Crop defaults as in the reference application. The overlay shows Always by +//! default, and Reset Tool brings back Always and Rule of Thirds. Fill goes back to its default +//! entry when another document that was already open becomes active, and keeps Content-Aware +//! Fill otherwise. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::crop_overlay::{CropOverlay, OverlayShow}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.sync_views(); + app +} + +/// A control request as the control channel sends it, then the frame's sync. +fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); + rx.try_recv().unwrap_or(Value::Null) +} + +/// Pick a document by its Window menu row, as the tab bar does. +fn activate(app: &mut PhotocraftApp, index: usize) { + control(app, "ui.menu.invoke", json!({"id": format!("window.document.{index}")})); + assert_eq!(app.session.active_index(), Some(index), "document {index} is active"); +} + +fn content_aware(app: &PhotocraftApp) -> bool { + app.ui.tool_options.crop_content_aware +} + +#[test] +fn the_overlay_shows_always_by_default_and_reset_tool_brings_it_back() { + let mut app = app(); + let o = &app.ui.tool_options; + assert_eq!((o.crop_overlay, o.crop_overlay_show), (CropOverlay::Thirds, OverlayShow::Always), "a fresh Crop tool"); + + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_overlay = CropOverlay::Diagonal; + app.ui.tool_options.crop_overlay_show = OverlayShow::Auto; + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + let o = &app.ui.tool_options; + assert_eq!((o.crop_overlay, o.crop_overlay_show), (CropOverlay::Thirds, OverlayShow::Always), "Reset Tool"); + + app.ui.tool_options.crop_overlay_show = OverlayShow::Never; + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(app.ui.tool_options.crop_overlay_show, OverlayShow::Always, "Reset Tool from Never too"); +} + +#[test] +fn a_show_mode_already_saved_keeps_its_value() { + for (saved, want) in [("auto", OverlayShow::Auto), ("never", OverlayShow::Never), ("always", OverlayShow::Always)] { + let o: photocraft_ui_egui::state::ToolOptions = serde_json::from_value(json!({"crop_overlay_show": saved})).unwrap(); + assert_eq!(o.crop_overlay_show, want, "a saved `{saved}` loads as it was saved"); + } +} + +#[test] +fn fill_is_kept_by_tool_switches_and_new_documents() { + let mut app = app(); + app.ui.tool_options.crop_content_aware = true; + + app.ui.tool = Tool::Brush; + app.sync_views(); + app.ui.tool = Tool::Crop; + app.sync_views(); + assert!(content_aware(&app), "switch to another tool and back: kept"); + + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + assert_eq!(app.session.active_index(), Some(1), "the new document is active"); + assert!(content_aware(&app), "create a new document: kept"); +} + +#[test] +fn fill_is_kept_when_a_crop_is_committed_or_cancelled() { + let mut app = app(); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_content_aware = true; + + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([8.0, 8.0, 40.0, 32.0]); + photocraft_ui_egui::canvas::commit_crop(&mut app); + app.sync_views(); + let size = app.session.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (32, 24), "the crop was committed"); + assert!(content_aware(&app), "commit a crop: kept"); + + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([4.0, 4.0, 20.0, 16.0]); + photocraft_ui_egui::crop_ui::cancel(&mut app); + app.sync_views(); + assert_eq!(app.session.active().unwrap().doc.size.width, 32, "the cancelled crop changed nothing"); + assert!(content_aware(&app), "cancel a crop: kept"); +} + +#[test] +fn fill_goes_back_to_the_default_entry_when_another_open_document_becomes_active() { + let mut app = app(); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + + // A tab switch to a document that was already open. + app.ui.tool_options.crop_content_aware = true; + activate(&mut app, 0); + assert!(!content_aware(&app), "make another already-open document active: back to the default"); + + // Switching back to the first one doesn't bring it back. + activate(&mut app, 1); + assert!(!content_aware(&app), "switch back to the first document: stays at the default"); + + // Closing the active document makes another open one active. + app.ui.tool_options.crop_content_aware = true; + control(&mut app, "ui.menu.invoke", json!({"id": "file.close"})); + assert_eq!(app.session.documents().len(), 1, "one document closed"); + assert!(!content_aware(&app), "close the active document so another becomes active: back to the default"); +} + +#[test] +fn fill_is_kept_while_the_same_document_stays_active() { + let mut app = app(); + app.ui.tool_options.crop_content_aware = true; + // Repeated syncs of the same document, as every frame does, leave it alone. + for _ in 0..3 { + app.sync_views(); + } + assert!(content_aware(&app)); + // An edit replaces the snapshot without changing the document. + app.run("layer.new.layer", json!({})).unwrap(); + app.sync_views(); + assert!(content_aware(&app), "an edit in the active document keeps it"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477e..61bc3504b63 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -13,6 +13,7 @@ mod clipboard_svg; mod color_managed_canvas; mod crop_beyond_canvas; mod crop_default_mode; +mod crop_defaults; mod crop_popup_keys; mod custom_print_paper; mod display_shadows; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 609363ca9d4..75a73aa63f3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,7 +59,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks, as four floats 0..1 (not 0..255). -- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. +- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`always`: the whole time a crop is pending, the default, as in the reference application; `auto`: only while the crop box is dragged; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. - `ui.set` also accepts the Pen tools' options bar (#1397): `penMode` (`path` or `shape`) and `penPathOp`, the Path Operations menu (`newLayer`, `combine`, `subtract`, `intersect`, `exclude`): how the next Pen, Freeform Pen or Curvature Pen path joins the selected shape layer (Shape mode; `newLayer` makes a new one) or the targeted vector mask, selected path or Work Path (Path mode, where `newLayer` combines). `ui.inspect` reports them under `toolOptions` (`vector_mode`, `pen_path_op`). Merge Shape Components is the engine command `path.mergeShapeComponents` (work, saved or `layer` path) or `layer.combineShapes.mergeShapeComponents` (shape layers). - `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. From 08b6431488317a612a6f3234c33dc530ff3d8526 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 15:44:37 +0500 Subject: [PATCH 02/14] Crop: an untouched frame shows only a dashed outline; the shield and overlay start with the first change --- crates/ui-egui/src/canvas/paint.rs | 53 ++++++++++-- crates/ui-egui/src/tools/crop_overlay.rs | 37 +++++++- crates/ui-egui/src/tools/crop_shield.rs | 6 ++ crates/ui-egui/tests/it/crop_frame_look.rs | 98 ++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 5 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/crop_frame_look.rs diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index 0be47adf8de..ce91a609f4a 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -191,7 +191,7 @@ pub(crate) fn marching_ants_line_shapes(painter: &egui::Painter, lines: impl Ite /// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright /// frame, the composition `guides` (unit-square polylines, `crop_overlay::guides`), corner and /// edge handles. -fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guides: &[crate::crop_overlay::Polyline]) { +fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guides: &[crate::crop_overlay::Polyline], look: crate::crop_overlay::FrameLook) { let clip = painter.clip_rect(); if let Some(dim) = shield { for band in [ @@ -203,9 +203,8 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guide painter.rect_filled(band, 0.0, dim); } } - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); + let h = crop_frame_outline(painter, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], look); draw_crop_guides(painter, guides, |a, b| r.min + r.size() * vec2(a, b)); - let h = Stroke::new(3.0, Color32::WHITE); let l = 14.0f32.min(r.width() / 3.0).min(r.height() / 3.0); for (c, dx, dy) in [(r.left_top(), 1.0, 1.0), (r.right_top(), -1.0, 1.0), (r.left_bottom(), 1.0, -1.0), (r.right_bottom(), -1.0, -1.0)] { painter.line_segment([c, c + vec2(l * dx, 0.0)], h); @@ -221,7 +220,13 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guide /// [`crop_overlay`] for a turned frame (or a rotated view): `q` is the frame's top-left, top-right, /// bottom-right and bottom-left corner on screen, a parallelogram. -fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option, guides: &[crate::crop_overlay::Polyline]) { +fn crop_overlay_turned( + painter: &egui::Painter, + q: [Pos2; 4], + shield: Option, + guides: &[crate::crop_overlay::Polyline], + look: crate::crop_overlay::FrameLook, +) { let (u, v) = (q[1] - q[0], q[3] - q[0]); let (lu, lv) = (u.length(), v.length()); if !(lu.is_finite() && lv.is_finite()) || lu < 1e-3 || lv < 1e-3 { @@ -245,9 +250,8 @@ fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option Stroke { + use crate::crop_overlay::FrameLook; + let grey = Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).text_faint); + match look { + FrameLook::Classic => { + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::WHITE))); + Stroke::new(3.0, Color32::WHITE) + } + FrameLook::Untouched => { + // Dashed like a selection outline: 4 px black, then 4 px light grey. Static rather + // than marching, so an untouched frame doesn't keep the canvas repainting. + let ring: Vec = q.iter().copied().chain(q.first().copied()).collect(); + painter.add(egui::Shape::line(ring.clone(), Stroke::new(1.0, Color32::from_gray(210)))); + painter.extend(egui::Shape::dashed_line(&ring, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0)); + grey + } + FrameLook::Started => { + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::from_white_alpha(220)))); + grey + } + } +} + /// The crop overlay's guides, each unit-square point placed on screen by `at`. fn draw_crop_guides(painter: &egui::Painter, guides: &[crate::crop_overlay::Polyline], at: impl Fn(f32, f32) -> Pos2) { let thin = Stroke::new(1.0, Color32::from_white_alpha(90)); @@ -474,10 +505,16 @@ pub(super) fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: let shield = crate::crop_shield::current(app, painter.ctx()); if deg == 0.0 && xf.rotation == 0.0 { let r = Rect::from_two_pos(xf.to_screen(c[0] as f32, c[1] as f32), xf.to_screen(c[2] as f32, c[3] as f32)); - crop_overlay(painter, r, shield, &crate::crop_overlay::current_guides(app, r.width(), r.height())); + crop_overlay(painter, r, shield, &crate::crop_overlay::current_guides(app, r.width(), r.height()), crate::crop_overlay::frame_look(app)); } else { let q = crate::crop_ui::corners(c, deg).map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); - crop_overlay_turned(painter, q, shield, &crate::crop_overlay::current_guides(app, q[0].distance(q[1]), q[0].distance(q[3]))); + crop_overlay_turned( + painter, + q, + shield, + &crate::crop_overlay::current_guides(app, q[0].distance(q[1]), q[0].distance(q[3])), + crate::crop_overlay::frame_look(app), + ); } // The frame's angle beside the pointer while it turns (#1792). if let (Some(a), Some(h)) = (crate::crop_ui::turning(app), hover) { diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index 0d12f0a949c..d5a862baa62 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -239,12 +239,44 @@ fn golden_spiral() -> Polyline { /// The guides the pending crop box shows now: none when the options say not to. pub fn current_guides(app: &PhotocraftApp, w: f32, h: f32) -> Vec { let o = &app.ui.tool_options; - if app.ui.tool != Tool::Crop || !o.crop_overlay_show.shows(app.crop.drag.is_some()) { + if app.ui.tool != Tool::Crop || !crop_started(app) || !o.crop_overlay_show.shows(app.crop.drag.is_some()) { return Vec::new(); } guides(o.crop_overlay, o.crop_overlay_orientation, w, h) } +/// Whether the crop has started: its frame was changed since it was made (it is no longer the +/// untouched default frame), or is being changed now. Until then the reference application shows +/// only the frame, with no shield and no overlay in any show mode; both start with the first change +/// and stay until the crop is committed or cancelled (#10930). Classic Mode wasn't measured and +/// keeps the behaviour it had. +pub fn crop_started(app: &PhotocraftApp) -> bool { + app.ui.tool_options.crop_shield.classic_mode || app.crop.drag.is_some() || !app.crop.default_frame +} + +/// How the crop frame's outline and handles are drawn (#10930). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameLook { + /// Before the first change: a dashed outline, black on light grey like a selection's, and grey + /// handles. + Untouched, + /// Once the crop has started: a solid, thin, light outline and the same grey handles. + Started, + /// Classic Mode, which wasn't measured: the solid white outline and handles it always had. + Classic, +} + +/// The look of the pending crop frame. +pub fn frame_look(app: &PhotocraftApp) -> FrameLook { + if app.ui.tool_options.crop_shield.classic_mode { + FrameLook::Classic + } else if crop_started(app) { + FrameLook::Started + } else { + FrameLook::Untouched + } +} + /// O: the next overlay (the orientation starts over). pub fn cycle(o: &mut ToolOptions) { o.crop_overlay = o.crop_overlay.next(); @@ -551,6 +583,9 @@ mod tests { app.crop.drag = None; app.ui.tool_options.crop_overlay_show = OverlayShow::Always; app.ui.tool_options.crop_overlay = CropOverlay::Triangle; + assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Always: still nothing on the untouched frame (#10930)"); + // The frame has been changed, so the crop is pending: Always keeps showing after release. + app.crop.default_frame = false; assert_eq!(current_guides(&app, 300.0, 200.0).len(), 3); app.ui.tool = Tool::Brush; assert!(current_guides(&app, 300.0, 200.0).is_empty(), "a pending frame under another tool shows no guide"); diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index 4ffaf190626..b215b1a0a16 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -112,6 +112,10 @@ pub fn canvas_color(app: &PhotocraftApp, ctx: &egui::Context) -> Color32 { /// The shield over the pending crop box now ([`fill`]). pub fn current(app: &PhotocraftApp, ctx: &egui::Context) -> Option { + // No shield before the crop has started: an untouched frame shows only itself (#10930). + if !crate::crop_overlay::crop_started(app) { + return None; + } fill(&app.ui.tool_options.crop_shield, canvas_color(app, ctx), app.crop.drag.is_some()) } @@ -311,6 +315,8 @@ mod tests { #[test] fn match_canvas_is_the_black_full_screen_pasteboard() { let mut app = crop_app(); + // A shield shows only once the crop has started (#10930); this test is about its colour. + app.crop.default_frame = false; let ctx = egui::Context::default(); let theme_canvas = crate::theme::Tokens::get(&ctx).canvas; assert_ne!(theme_canvas, Color32::BLACK); diff --git a/crates/ui-egui/tests/it/crop_frame_look.rs b/crates/ui-egui/tests/it/crop_frame_look.rs new file mode 100644 index 00000000000..8301fc101fd --- /dev/null +++ b/crates/ui-egui/tests/it/crop_frame_look.rs @@ -0,0 +1,98 @@ +//! #10930: before the first change to a crop frame the reference application shows only a dashed +//! outline with grey handles, with no shield and no overlay in any show mode. The shield and the +//! overlay start with the first change and stay until the crop is committed or cancelled. These +//! test the decisions the canvas draws from (`current_guides`, `crop_shield::current`, +//! `frame_look`), driven by real Crop gestures through the control channel. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::crop_overlay::{FrameLook, OverlayShow, current_guides, frame_look}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn control(app: &mut PhotocraftApp, method: &str, params: Value) { + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); +} + +/// A document with the Crop tool showing its untouched default frame, overlay mode `show`. +fn crop_app(show: OverlayShow) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_overlay_show = show; + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some(), "the Crop tool shows a frame"); + app +} + +/// What the canvas would draw now: (guides shown, shield shown, outline look). +fn drawn(app: &PhotocraftApp) -> (bool, bool, FrameLook) { + let ctx = egui::Context::default(); + let guides = !current_guides(app, 300.0, 200.0).is_empty(); + let shield = photocraft_ui_egui::crop_shield::current(app, &ctx).is_some(); + (guides, shield, frame_look(app)) +} + +/// Press inside the untouched frame and drag: that draws a new frame, the first change. +fn press_and_drag(app: &mut PhotocraftApp) { + control(app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}, {"kind": "move", "x": 150, "y": 90}]})); +} + +fn release(app: &mut PhotocraftApp) { + control(app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "up", "x": 150, "y": 90}]})); +} + +const MODES: [OverlayShow; 3] = [OverlayShow::Always, OverlayShow::Auto, OverlayShow::Never]; + +#[test] +fn an_untouched_frame_shows_only_a_dashed_outline_in_every_show_mode() { + for show in MODES { + let app = crop_app(show); + assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: no overlay, no shield, dashed outline"); + } +} + +#[test] +fn the_shield_and_the_overlay_start_with_the_first_change() { + // (mode, overlay while dragging, overlay after release with the crop still pending) + for (show, dragging, released) in [(OverlayShow::Always, true, true), (OverlayShow::Auto, true, false), (OverlayShow::Never, false, false)] { + let mut app = crop_app(show); + press_and_drag(&mut app); + assert_eq!(drawn(&app), (dragging, true, FrameLook::Started), "{show:?} mid-drag: shield on, solid outline"); + release(&mut app); + assert!(app.ui.crop_rect.is_some(), "{show:?}: the crop is still pending after release"); + assert_eq!(drawn(&app), (released, true, FrameLook::Started), "{show:?} after release: the shield stays until commit or cancel"); + } +} + +#[test] +fn committing_or_cancelling_brings_back_the_untouched_look() { + for show in MODES { + let mut app = crop_app(show); + press_and_drag(&mut app); + release(&mut app); + photocraft_ui_egui::canvas::commit_crop(&mut app); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?} after commit: a new untouched frame"); + + press_and_drag(&mut app); + release(&mut app); + assert!(drawn(&app).1, "{show:?}: a second crop starts again"); + photocraft_ui_egui::crop_ui::cancel(&mut app); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?} after cancel: a new untouched frame"); + } +} + +#[test] +fn classic_mode_keeps_the_look_it_had() { + // Classic Mode wasn't measured, so it keeps its solid white outline, shield and overlay rule. + let mut app = crop_app(OverlayShow::Always); + app.ui.tool_options.crop_shield.classic_mode = true; + let (guides, shield, look) = drawn(&app); + assert_eq!(look, FrameLook::Classic); + assert!(guides && shield, "Classic Mode shows its overlay and shield as before, even untouched"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 61bc3504b63..17eee6e0862 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -14,6 +14,7 @@ mod color_managed_canvas; mod crop_beyond_canvas; mod crop_default_mode; mod crop_defaults; +mod crop_frame_look; mod crop_popup_keys; mod custom_print_paper; mod display_shadows; From f5931605373d9cd0ab3ec9d290cf9959fcfb03ea Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 16:25:13 +0500 Subject: [PATCH 03/14] Crop: a press that hasn't moved is not a change, Show Cropped Area off keeps its fill, and the dashed outline is cut to the view --- crates/ui-egui/src/canvas/paint.rs | 80 +++++++++++++++++++++- crates/ui-egui/src/tools/crop_overlay.rs | 17 ++++- crates/ui-egui/src/tools/crop_shield.rs | 6 +- crates/ui-egui/tests/it/crop_frame_look.rs | 21 ++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index ce91a609f4a..a817bcfae66 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -280,9 +280,16 @@ fn crop_frame_outline(painter: &egui::Painter, q: [Pos2; 4], look: crate::crop_o FrameLook::Untouched => { // Dashed like a selection outline: 4 px black, then 4 px light grey. Static rather // than marching, so an untouched frame doesn't keep the canvas repainting. - let ring: Vec = q.iter().copied().chain(q.first().copied()).collect(); - painter.add(egui::Shape::line(ring.clone(), Stroke::new(1.0, Color32::from_gray(210)))); - painter.extend(egui::Shape::dashed_line(&ring, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0)); + // Each edge is cut to the visible area first: zoomed far in, a frame's edges run + // thousands of points off screen, and dashing them all would make a shape per dash. + let view = painter.clip_rect().expand(4.0); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + if let Some([a, b]) = clip_segment(a, b, view) { + painter.line_segment([a, b], Stroke::new(1.0, Color32::from_gray(210))); + painter.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(1.0, Color32::BLACK), 4.0, 4.0)); + } + } grey } FrameLook::Started => { @@ -292,6 +299,30 @@ fn crop_frame_outline(painter: &egui::Painter, q: [Pos2; 4], look: crate::crop_o } } +/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or None when none of it is. +fn clip_segment(a: Pos2, b: Pos2, r: Rect) -> Option<[Pos2; 2]> { + let d = b - a; + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + continue; + } + let t = q / p; + if p < 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + if t0 > t1 { + return None; + } + } + (t0.is_finite() && t1.is_finite()).then(|| [a + d * t0, a + d * t1]) +} + /// The crop overlay's guides, each unit-square point placed on screen by `at`. fn draw_crop_guides(painter: &egui::Painter, guides: &[crate::crop_overlay::Polyline], at: impl Fn(f32, f32) -> Pos2) { let thin = Stroke::new(1.0, Color32::from_white_alpha(90)); @@ -661,3 +692,46 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter _ => {} } } + +#[cfg(test)] +mod clip_segment_tests { + use super::{Pos2, Rect, clip_segment, pos2}; + + fn view() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)) + } + + /// Within a hundredth of a point, far below a device pixel: f32 on edges tens of thousands of + /// points long can't do better, and the screen doesn't need it to. + fn near(a: Pos2, b: Pos2) -> bool { + (a - b).length() < 1e-2 + } + + #[test] + fn a_segment_inside_is_kept_whole() { + let [a, b] = clip_segment(pos2(10.0, 10.0), pos2(90.0, 40.0), view()).unwrap(); + assert!(near(a, pos2(10.0, 10.0)) && near(b, pos2(90.0, 40.0))); + } + + #[test] + fn a_segment_running_far_off_screen_is_cut_to_the_view() { + // A frame edge at 3200 % runs tens of thousands of points past the view. + let [a, b] = clip_segment(pos2(-40_000.0, 20.0), pos2(40_000.0, 20.0), view()).unwrap(); + assert!(near(a, pos2(0.0, 20.0)) && near(b, pos2(100.0, 20.0)), "{a:?} {b:?}"); + let [a, b] = clip_segment(pos2(50.0, -1000.0), pos2(50.0, 1000.0), view()).unwrap(); + assert!(near(a, pos2(50.0, 0.0)) && near(b, pos2(50.0, 50.0))); + } + + #[test] + fn a_slanted_segment_through_a_corner_is_cut_on_both_sides() { + let [a, b] = clip_segment(pos2(-10.0, -10.0), pos2(60.0, 60.0), view()).unwrap(); + assert!(near(a, pos2(0.0, 0.0)) && near(b, pos2(50.0, 50.0)), "{a:?} {b:?}"); + } + + #[test] + fn a_segment_outside_the_view_draws_nothing() { + assert!(clip_segment(pos2(-50.0, 10.0), pos2(-5.0, 40.0), view()).is_none(), "left of it"); + assert!(clip_segment(pos2(10.0, 60.0), pos2(90.0, 90.0), view()).is_none(), "below it"); + assert!(clip_segment(pos2(-10.0, 70.0), pos2(70.0, 200.0), view()).is_none(), "past a corner"); + } +} diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index d5a862baa62..a1b0e67d770 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -246,12 +246,25 @@ pub fn current_guides(app: &PhotocraftApp, w: f32, h: f32) -> Vec { } /// Whether the crop has started: its frame was changed since it was made (it is no longer the -/// untouched default frame), or is being changed now. Until then the reference application shows +/// untouched default frame), or a drag is changing it now. Until then the reference application shows /// only the frame, with no shield and no overlay in any show mode; both start with the first change /// and stay until the crop is committed or cancelled (#10930). Classic Mode wasn't measured and /// keeps the behaviour it had. pub fn crop_started(app: &PhotocraftApp) -> bool { - app.ui.tool_options.crop_shield.classic_mode || app.crop.drag.is_some() || !app.crop.default_frame + app.ui.tool_options.crop_shield.classic_mode || !app.crop.default_frame || drag_changed(app) +} + +/// Whether the drag in progress has changed the frame since its press. A press that hasn't moved +/// anything yet is not the first change, so it leaves the untouched look (#10930). The tests mirror +/// the ones `crop_ui` uses when a drag ends. +fn drag_changed(app: &PhotocraftApp) -> bool { + use crate::crop_ui::CropDrag; + match &app.crop.drag { + None => false, + Some(CropDrag::Draw { anchor, cur, .. }) => anchor != cur, + Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) => app.ui.crop_rect != Some(*rect), + Some(CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. }) => crate::crop_ui::angle(app) != *from, + } } /// How the crop frame's outline and handles are drawn (#10930). diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index b215b1a0a16..eda1a7cd7d6 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -112,11 +112,13 @@ pub fn canvas_color(app: &PhotocraftApp, ctx: &egui::Context) -> Color32 { /// The shield over the pending crop box now ([`fill`]). pub fn current(app: &PhotocraftApp, ctx: &egui::Context) -> Option { + let s = &app.ui.tool_options.crop_shield; // No shield before the crop has started: an untouched frame shows only itself (#10930). - if !crate::crop_overlay::crop_started(app) { + // Show Cropped Area off still hides everything outside the frame, as it does once started. + if s.shows_cropped_area() && !crate::crop_overlay::crop_started(app) { return None; } - fill(&app.ui.tool_options.crop_shield, canvas_color(app, ctx), app.crop.drag.is_some()) + fill(s, canvas_color(app, ctx), app.crop.drag.is_some()) } /// Show Cropped Area is off with the Crop tool showing a box: nothing outside it shows. diff --git a/crates/ui-egui/tests/it/crop_frame_look.rs b/crates/ui-egui/tests/it/crop_frame_look.rs index 8301fc101fd..29f05fbf11e 100644 --- a/crates/ui-egui/tests/it/crop_frame_look.rs +++ b/crates/ui-egui/tests/it/crop_frame_look.rs @@ -96,3 +96,24 @@ fn classic_mode_keeps_the_look_it_had() { assert_eq!(look, FrameLook::Classic); assert!(guides && shield, "Classic Mode shows its overlay and shield as before, even untouched"); } + +#[test] +fn a_press_that_has_not_moved_yet_is_not_a_change() { + // The first change is a drag; a press held still leaves the untouched look in every mode. + for show in MODES { + let mut app = crop_app(show); + control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}]})); + assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: pressed, not moved"); + control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "move", "x": 150, "y": 90}]})); + assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: the move is the first change"); + } +} + +#[test] +fn show_cropped_area_off_still_hides_what_is_outside_an_untouched_frame() { + let mut app = crop_app(OverlayShow::Always); + app.ui.tool_options.crop_shield.show_cropped_area = false; + let ctx = egui::Context::default(); + assert!(photocraft_ui_egui::crop_shield::current(&app, &ctx).is_some(), "the opaque outside fill stays"); + assert!(current_guides(&app, 300.0, 200.0).is_empty(), "but no overlay before the first change"); +} From a28aa2182870b4282586b18c030e2d8181040462 Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Wed, 4 Nov 2026 09:51:52 +0700 Subject: [PATCH 04/14] fix(transform): refuse the Background among several layers and apply the box on a selection change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free Transform of several selected layers left the Background out without a word, where Photoshop refuses with its locked-layer alert. And with the box open, taking a layer out of the selection (or adding one) without changing the active layer left the box open, still moving the layers it started with. - targets_of keeps the Background; edit.transform's selection default refuses with `layer "Background" is locked`, and Ctrl+T / Edit › Transform show the same alert as a position-locked layer. An explicit `layers` list and the Background alone keep today's behaviour. - Move › Show Transform Controls shows no box for that selection either. - Any change to the selected layers while a layer box is open applies the box to the layers it moves, and doesn't open it again. Fixes #11101 --- crates/engine/src/cmd/transform_cmds.rs | 26 ++- .../src/cmd/transform_cmds/multi_tests.rs | 10 +- .../ui-egui/src/canvas/transform_controls.rs | 11 +- .../src/canvas/transform_controls_tests.rs | 5 +- crates/ui-egui/src/lib.rs | 3 + .../ui-egui/src/tools/transform_tool/begin.rs | 3 +- .../src/tools/transform_tool/commit.rs | 23 ++- .../src/transform_selection_rules_tests.rs | 164 ++++++++++++++++++ crates/ui-egui/src/transform_several_tests.rs | 24 +-- 9 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/transform_selection_rules_tests.rs diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 55f3d9a39ef..9b64677802d 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -266,9 +266,9 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { } /// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several -/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but -/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or -/// only the Background besides it), that layer; the active layer when nothing else is. +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501). The +/// Background stays among them, so such a box refuses as locked (#11101, [`background_among`]). +/// With one layer selected, that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { targets_of(&st.doc, &st.selected_layers(), st.active_layer) } @@ -279,10 +279,19 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + let ids = crate::layer_multi_cmds::top_level(doc, selected); if ids.is_empty() { active.into_iter().collect() } else { ids } } +/// The Background when it is among several layers one box would move: Photoshop refuses that Free +/// Transform with its locked-layer alert rather than leaving the Background out (#11101). +pub fn background_among(doc: &Document, ids: &[LayerId]) -> Option { + if ids.len() < 2 { + return None; + } + ids.iter().copied().find(|id| doc.is_background(*id)) +} + /// The `layers` param: the layers one box transforms together (at most 10000, each in the /// document), without those a listed group carries. `None` when it isn't given. fn layers_param(doc: &Document, p: &Value) -> Result>> { @@ -821,7 +830,14 @@ fn transform(s: &mut Session, p: &Value) -> Result { (Some(ids), _) => ids, _ if lone.is_some() => Vec::new(), (None, Some(id)) => vec![id], - (None, None) => selection_targets(st), + (None, None) => { + let ids = selection_targets(st); + if let Some(bg) = background_among(&st.doc, &ids) { + let name = st.doc.layer(bg).map_or("Background", |l| l.name.as_str()); + return Err(EngineError::Other(format!("layer \"{name}\" is locked"))); + } + ids + } }; if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { return Err(EngineError::NoLayer(*missing)); diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index e26572d6e7d..9c0cd7044bb 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -91,13 +91,17 @@ fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { } #[test] -fn the_background_stays_out_of_a_selection_of_several() { +fn the_background_among_a_selection_of_several_refuses_as_locked() { + // Photoshop shows its locked-layer alert rather than leaving the Background out (#11101). let (mut s, paint, text) = two_layers(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); - let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); - assert_eq!(r["layers"], json!([paint.0, text.0])); let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap_err().to_string(); + assert!(err.contains("\"Background\" is locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even {paint:?} or {text:?}"); assert!(st.doc.is_background(bg), "still the Background"); assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); } diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 7e00fd8cb39..a458b75ba81 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -22,7 +22,8 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option