From e8b906f5d997b61d22d945ff4095b195a9acb005 Mon Sep 17 00:00:00 2001 From: x-labb <154824493+x-labb@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:35:59 +0100 Subject: [PATCH 01/42] fix(ui): Free Transform keeps plain layers over the moving one under a blend or adjustment layer (#9387) The preview draws the layers over the moving layer on top of it again, but the search for them stopped at the first layer that isn't plain (an adjustment, a blend mode), so every layer under that one, including plain Normal layers over the moving layer, stayed under the box and the moving layer was drawn over them. A PSD usually has such a layer somewhere on top. Pass over layers that can't be drawn alone instead of stopping, so the plain layers around them still cover the moving layer. An adjustment layer over covering layers stays in the document under the box and is composited with them too, so they keep its adjustment. Layers with a blend mode still stay under the box, and the moving layer is not adjusted or blended by what is over it in the preview. --- .../src/tools/transform_tool/preview.rs | 49 +++++++--- .../ui-egui/src/tools/transform_tool/tests.rs | 91 +++++++++++++++++-- 2 files changed, 120 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index d39d6575f3..f7ec84fffe 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -78,13 +78,16 @@ pub(super) struct Above { pub(super) max_side: usize, /// The covering layers (whole layers or groups, at any depth). pub(super) ids: Vec, + /// Adjustment layers over the covering ones: they stay in the document under the box as well, + /// and are composited with the covering layers so those keep the adjustment. + pub(super) applied: Vec, /// Their composite and the canvas area it covers; `None` when nothing visible covers the layer. pub(super) shown: Option<(crate::transform_tex::PreviewTextures, photocraft_geom::Rect)>, } impl Above { pub(super) fn new(ctx: &egui::Context, name: String, doc: &Document, id: LayerId, max_side: usize) -> Self { - let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), shown: None }; + let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), applied: Vec::new(), shown: None }; a.rebuild(doc, id); a } @@ -92,11 +95,16 @@ impl Above { /// Composite what covers layer `id` of `doc` (the live document) again. pub(super) fn rebuild(&mut self, doc: &Document, id: LayerId) { self.ids.clear(); - covering(&doc.layers, id, &mut self.ids); + self.applied.clear(); + covering(&doc.layers, id, &mut self.ids, &mut self.applied); let mut up = doc.clone(); - show_only(&mut up.layers, &self.ids); + let mut covers = doc.clone(); + show_only(&mut covers.layers, &self.ids); + let keep: Vec = self.ids.iter().chain(&self.applied).copied().collect(); + show_only(&mut up.layers, &keep); let canvas = up.bounds(); - let rect = up + // An adjustment touches only pixels the covering layers have, so they alone bound the composite. + let rect = covers .layers .iter() .filter(|l| l.visible) @@ -174,11 +182,15 @@ fn plain(l: &photocraft_doc::Layer) -> bool { } /// The layers over `id` in `layers` (bottom to top, groups holding `id` searched too) that cover -/// it in the preview: from the top down, while they are [`plain`]. The first layer that isn't -/// stops the search, so it and everything under it stay in the document under the box as before -/// (an adjustment there keeps applying to the layers under it). A clipping group goes as a whole, -/// and the layers clipped to `id` (or to the group holding it) stay with it. -pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec) { +/// it in the preview, into `out`: every [`plain`] one, from the top down. A layer that isn't plain +/// (an adjustment, a blend mode) is passed over and stays in the document under the box: it can't +/// be drawn on its own, so the plain layers under it no longer wait for it to cover the moving +/// layer (#9387). An adjustment layer over covering layers goes to `applied`: it stays under the +/// box and is composited with them as well, so they keep its adjustment. A clipping group goes as +/// a whole, and the layers clipped to `id` (or to the group holding it) stay with it. +pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec, applied: &mut Vec) { + // Adjustments met since the last covering layer: they count once one lies under them. + let mut pending: Vec = Vec::new(); let mut end = layers.len(); while end > 0 { // The clipping group ending at `end`: its base and the layers clipped to it. @@ -194,14 +206,25 @@ pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut && let LayerContent::Group(group) = &g.content && matches!(g.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough) { - covering(&group.children, id, out); + let before = out.len(); + covering(&group.children, id, out, applied); + if out.len() > before { + applied.append(&mut pending); + } } return; } - if !unit.iter().all(plain) { - return; + if unit.iter().all(plain) { + out.extend(unit.iter().map(|l| l.id)); + if unit.iter().any(|l| l.visible) { + applied.append(&mut pending); + } + } else if let [l] = unit + && l.visible + && matches!(l.content, LayerContent::Adjustment(_)) + { + pending.push(l.id); } - out.extend(unit.iter().map(|l| l.id)); end = start; } } diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 57d323b84a..3621aad6ae 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -1921,6 +1921,53 @@ fn free_transform_keeps_the_layer_under_the_layers_over_it() { cancel(&mut app); } +/// #9387: a layer under others keeps its place in the stack when something over it blends or +/// adjusts (a PSD usually has one): the plain layers over the moving one still cover it. +#[test] +fn plain_layers_still_cover_the_moving_one_under_a_blend_or_adjustment_layer() { + use photocraft_color::BlendMode; + for adjustment in [false, true] { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let red = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(16, 16, 40, 40), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let blue = app.session.active().unwrap().active_layer.unwrap(); + let bg = app.session.active().unwrap().doc.layers[0].id; + let top = if adjustment { + app.session.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + } else { + app.session.execute("layer.new.layer", json!({})).unwrap(); + let top = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("blend", |doc, _| { + doc.layer_mut(top).unwrap().blend = BlendMode::Multiply; + Ok(()) + }) + .unwrap(); + top + }; + app.session.execute("layer.select", json!({"layer": red.0})).unwrap(); + begin(&mut app, &gpu_ctx()).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(!pv.doc.layer(blue).unwrap().visible, "adjustment={adjustment}: the plain layer is drawn over the box, not under it"); + assert!(pv.doc.layer(top).unwrap().visible, "adjustment={adjustment}: the layer that blends stays in the document under the box"); + assert!(pv.doc.layer(bg).unwrap().visible); + let (texture, rect) = pv.above.as_ref().and_then(|a| a.shown.as_ref()).expect("the plain layer covers the moving one"); + assert_eq!(*rect, photocraft_geom::Rect::new(16, 16, 40, 40), "adjustment={adjustment}"); + let (image, _) = texture.level_image(0).unwrap(); + let centre = image.pixels[12 * image.size[0] + 12]; + // The adjustment is composited with the layer it covers: inverted blue is yellow. + assert_eq!(centre, if adjustment { Color32::from_rgb(255, 255, 0) } else { Color32::BLUE }, "adjustment={adjustment}"); + cancel(&mut app); + } +} + #[test] fn plain_layers_from_the_top_cover_the_moving_one() { use photocraft_color::BlendMode; @@ -1928,7 +1975,7 @@ fn plain_layers_from_the_top_cover_the_moving_one() { let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8); let covers = |layers: &[Layer], id: LayerId| { let mut out = Vec::new(); - covering(layers, id, &mut out); + covering(layers, id, &mut out, &mut Vec::new()); out }; // In a group: the group's layers over it, then the ones over the group; never the layers @@ -1941,7 +1988,7 @@ fn plain_layers_from_the_top_cover_the_moving_one() { assert_eq!(covers(&layers, c), [tp, inner], "a clipped layer: covered by what is over its clipping group"); // Under the box, the covering layers leave the document; over it, they alone show. - let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], shown: None }; + let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], applied: Vec::new(), shown: None }; let mut under = layers.clone(); above.hide_in(&mut under); let vis = |ls: &[Layer]| [ls[0].visible, ls[1].visible, ls[2].visible]; @@ -1955,20 +2002,50 @@ fn plain_layers_from_the_top_cover_the_moving_one() { }; assert_eq!(inside, [false, false, true]); - // A layer that blends with what is under it stops the search: it and everything under it - // stay in the document under the box. + // A layer that blends with what is under it can't be drawn on its own: it stays in the + // document under the box, and the plain layers around it still cover (#9387). layers[2].blend = BlendMode::Multiply; - assert!(covers(&layers, t).is_empty()); + assert_eq!(covers(&layers, t), [inner]); layers[2].blend = BlendMode::Normal; let mut multiply = px("multiply"); multiply.blend = BlendMode::Multiply; + let m = multiply.id; layers.insert(2, multiply); - assert_eq!(covers(&layers, t), [tp]); + assert_eq!(covers(&layers, t), [tp, inner]); + assert!(!covers(&layers, t).contains(&m)); // A group that isn't Normal or Pass Through keeps what is inside it. layers[1].blend = BlendMode::Screen; layers.remove(2); assert_eq!(covers(&layers, t), [tp]); - assert_eq!(covers(&layers, LayerId::fresh()), [tp], "not in the stack: down to the first layer that isn't plain"); + assert_eq!(covers(&layers, LayerId::fresh()), [tp, layers[0].id], "not in the stack: every plain layer, the Screen group passed over"); +} + +/// #9387: an adjustment layer over covering layers is composited with them as well as staying +/// under the box; one with nothing covering under it has nothing to adjust. +#[test] +fn adjustments_over_covering_layers_are_applied_to_them() { + use photocraft_doc::{Adjustment, Layer}; + let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8); + let adjustment = |name: &str| Layer::new(name, LayerContent::Adjustment(Adjustment::Invert)); + let find = |layers: &[Layer], id: LayerId| { + let (mut out, mut applied) = (Vec::new(), Vec::new()); + covering(layers, id, &mut out, &mut applied); + (out, applied) + }; + let (target, under, over, adj_top, adj_between, adj_under) = + (px("target"), px("under"), px("over"), adjustment("top"), adjustment("between"), adjustment("under")); + let (t, u, o, top, between, below) = (target.id, under.id, over.id, adj_top.id, adj_between.id, adj_under.id); + // Bottom to top: target, an adjustment with nothing to adjust over it, two plain layers + // around another adjustment, and an adjustment on top. + let layers = vec![target, adj_under, under, adj_between, over, adj_top]; + let (covers, applied) = find(&layers, t); + assert_eq!(covers, [o, u]); + assert_eq!(applied, [top, between], "adjustments over a covering layer, top down"); + assert!(!applied.contains(&below), "nothing covers under it"); + + // Nothing covers: no adjustment applies. + let alone = vec![px("a"), adjustment("b")]; + assert_eq!(find(&alone, alone[0].id), (Vec::new(), Vec::new())); } /// A context that reports a real GPU's texture limit (the default reports 2048). From f8763e2d5a05cbdbf9bd062bd6c6fae858631f61 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Tue, 27 Oct 2026 18:38:32 +0700 Subject: [PATCH 02/42] Fix Crop Clear retaining custom proportions --- crates/ui-egui/src/tools/crop_size.rs | 21 +++++++++++++ crates/ui-egui/tests/it/crop_default_mode.rs | 31 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index ee1b939e15..190ca1bf33 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -232,6 +232,7 @@ pub fn clear_ratio(o: &mut ToolOptions) { clear(o); } else { o.crop_ratio.clear(); + select_custom_ratio(o); } } @@ -385,6 +386,26 @@ mod tests { assert_eq!(width / height, 4.0 / 3.0); } + #[test] + fn clear_ratio_discards_complete_and_partial_ratio_fields() { + for (width, height) in [(16.0, 9.0), (4.0, 0.0), (0.0, 3.0)] { + let mut options = ToolOptions::default(); + set_custom_ratio_fields(&mut options, width, height); + clear_ratio(&mut options); + assert!(options.crop_ratio.is_empty()); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (0.0, 0.0)); + set_custom_ratio_fields(&mut options, 5.0, 0.0); + assert!(options.crop_ratio.is_empty(), "entering a new width must not reuse the old height"); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (5.0, 0.0)); + } + let mut options = whr("4 in", "6 in", "300", PX_PER_IN); + clear_ratio(&mut options); + assert_eq!( + (options.crop_ratio.as_str(), options.crop_width.as_str(), options.crop_height.as_str(), options.crop_resolution.as_str()), + (WHR, "", "", "") + ); + } + #[test] fn custom_ratio_keeps_three_decimals() { // #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop. diff --git a/crates/ui-egui/tests/it/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs index 7c426410db..5ef478fb50 100644 --- a/crates/ui-egui/tests/it/crop_default_mode.rs +++ b/crates/ui-egui/tests/it/crop_default_mode.rs @@ -238,3 +238,34 @@ fn a_resize_keeps_the_box_centre_after_a_space_pan() { let b = screen_box(&h); assert!(mid(b).distance(mid(box0)) < 0.5, "still where it was: {:?} vs {:?}", mid(b), mid(box0)); } + +#[test] +fn clear_removes_the_displayed_crop_proportions_without_changing_the_frame() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + let o = &mut h.state_mut().ui.tool_options; + o.crop_ratio = "16:9".into(); + o.crop_custom_ratio_width = 16.0; + o.crop_custom_ratio_height = 9.0; + h.run_steps(3); + assert!(h.query_all_by_value("16").next().is_some()); + assert!(h.query_all_by_value("9").next().is_some()); + let frame = h.state().ui.crop_rect; + let save = |h: &mut Harness, name: &str| { + if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(name)).unwrap(); + } + }; + save(&mut h, "crop-clear-before.png"); + h.query_all_by_label("Clear").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).expect("Crop options bar Clear").click(); + h.run_steps(3); + save(&mut h, "crop-clear-after.png"); + let o = &h.state().ui.tool_options; + assert!(o.crop_ratio.is_empty()); + assert_eq!((o.crop_custom_ratio_width, o.crop_custom_ratio_height), (0.0, 0.0)); + assert!(h.query_all_by_value("16").next().is_none()); + assert!(h.query_all_by_value("9").next().is_none()); + assert_eq!(h.state().ui.crop_rect, frame); +} From 77c8d4a06d1f2c62676364daf6ea827504540d22 Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:33:43 +0100 Subject: [PATCH 03/42] Crop: H and P pick the Hand and Pen tools over the default frame (#9731) After a committed crop the Crop tool shows its untouched default frame again, and H/P toggled Show Cropped Area and Classic Mode on it, so the Hand and Pen shortcuts did nothing. Both keys now act on the crop only while one is in progress (a frame drawn or changed), as in Photoshop. --- crates/ui-egui/src/tools/crop_mode/tests.rs | 26 ++++++++++++++++++--- crates/ui-egui/src/tools/crop_shield.rs | 18 ++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 5f059917c9..c1bfb44795 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -537,7 +537,7 @@ fn arrows_nudge_the_image_shift_by_ten_and_x_recentres() { #[test] fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); app.ui.tool_options.crop_shield.show_cropped_area = false; assert!(crate::crop_shield::hides_outside(&app)); app.ui.tool_options.crop_shield.classic_mode = true; @@ -554,9 +554,9 @@ fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { } #[test] -fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { +fn p_toggles_classic_mode_while_a_crop_is_in_progress() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); let press = |app: &mut PhotocraftApp| { let ctx = egui::Context::default(); ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); @@ -579,6 +579,26 @@ fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { assert!(!classic(&app)); } +/// After a commit the tool shows its untouched default frame again: H and P are the Hand and Pen +/// keys there, as when the tool is picked, not Show Cropped Area and Classic Mode (#9731, #6536). +#[test] +fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { + for (k, tool) in [(egui::Key::H, Tool::Hand), (egui::Key::P, Tool::Pen)] { + let mut app = app(); + with_frame(&mut app); + key(&mut app, egui::Key::Enter, NONE); + assert_eq!(app.session.active().map(|st| st.doc.size), Some(Size::new(60, 40)), "the crop committed"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "the default frame is back"); + key(&mut app, k, NONE); + assert_eq!(app.ui.tool, tool, "{k:?}"); + let s = &app.ui.tool_options.crop_shield; + assert!(s.show_cropped_area && !s.classic_mode, "{k:?} changed no crop option"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_none(), "leaving the tool drops the default frame"); + } +} + #[test] fn straighten_turns_the_image_so_the_line_is_level_on_screen() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index 4ffaf19062..6c5f36973b 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -120,11 +120,13 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.ui.tool_options.crop_shield.shows_cropped_area() } -/// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in +/// H with a crop in progress (a frame drawn or changed) and no drag toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic -/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. +/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's, +/// also over the untouched default frame the tool shows when picked or after a commit (#6536, +/// #9731). pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { + if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.default_frame || app.crop.drag.is_some() { return false; } if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { @@ -335,8 +337,10 @@ mod tests { } #[test] - fn h_toggles_show_cropped_area_while_the_crop_tool_shows_a_box() { + fn h_toggles_show_cropped_area_while_a_crop_is_in_progress() { let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; assert!(!hides_outside(&app)); press_h(&mut app); assert_eq!(app.ui.tool, Tool::Crop, "H doesn't switch to the Hand tool"); @@ -354,6 +358,12 @@ mod tests { press_h(&mut app); assert_eq!(app.ui.tool, Tool::Hand); assert!(app.ui.tool_options.crop_shield.show_cropped_area); + // The untouched default frame (#9731). + let mut app = crop_app(); + assert!(app.crop.default_frame); + press_h(&mut app); + assert_eq!(app.ui.tool, Tool::Hand); + assert!(app.ui.tool_options.crop_shield.show_cropped_area); // Another tool. let mut app = crop_app(); app.ui.tool = Tool::Brush; From 2bc47b5820f852f4fb2500ef4fa5334a4d7e2486 Mon Sep 17 00:00:00 2001 From: Cristi Radu Date: Sat, 31 Oct 2026 09:42:30 +0200 Subject: [PATCH 04/42] Crop: the Info panel shows the crop frame's W and H (#3145) While the Crop tool shows a frame, the Info panel's W and H are the frame's own size (before any turn), as in Photoshop, so a crop's ratio can be matched to another photo. Other tools still show the selection. The other #3145 symptoms are already fixed on main: the ratio fields (#3209), the crop box's Undo (#3510) and handle-snap jitter (#3206). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 29 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 8 +++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..dba56692c1 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -666,6 +666,16 @@ pub fn sizing(app: &PhotocraftApp) -> Option<[f64; 2]> { frame_size(app.ui.crop_rect?) } +/// The Info panel's W and H while the Crop tool shows a frame: the frame's size (its own, before +/// any turn), as Photoshop's Info panel shows it, so its ratio can be matched (#3145). `None` +/// with another tool or no frame, and the panel shows the selection's size. +pub fn info_size(app: &PhotocraftApp) -> Option<[f64; 2]> { + if app.ui.tool != Tool::Crop { + return None; + } + frame_size(app.ui.crop_rect?) +} + /// The Crop tool owns the arrow keys and X: a pending frame and no gesture in progress. pub fn takes_keys(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_none() && app.ui.transform.is_none() && app.ui.crop_rect.is_some_and(|r| r.iter().all(|v| v.is_finite())) @@ -2180,4 +2190,23 @@ mod tests { assert_eq!(frame_size([f64::NAN, 0.0, 1.0, 1.0]), None); assert_eq!(frame_size([-f64::MAX, 0.0, f64::MAX, 1.0]), None); } + + /// #3145: the Info panel shows the pending frame's W × H while the Crop tool is active + /// (between gestures too, and its own size when turned), and nothing for other tools. + #[test] + fn info_panel_shows_the_pending_frame_size() { + let mut app = app(SampleType::U8); + app.ui.tool = Tool::Crop; + app.ui.crop_rect = None; + assert_eq!(info_size(&app), None, "no frame"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 65.4]); + assert_eq!(info_size(&app), Some([80.0, 45.0]), "rounded as ↵ crops it"); + app.ui.crop_angle = 30.0; + assert_eq!(info_size(&app), Some([80.0, 45.0]), "a turned frame keeps its own size"); + app.ui.crop_rect = Some([f64::NAN, 0.0, 1.0, 1.0]); + assert_eq!(info_size(&app), None, "a broken frame shows nothing"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 60.0]); + app.ui.tool = Tool::Move; + assert_eq!(info_size(&app), None, "another tool shows the selection instead"); + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b6bd34d9d2..532af8ccca 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1878,7 +1878,9 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fx = move |px: f64| units.format(px, dpi, size.width as f64); let units_y = app.session.prefs().units_and_rulers.clone(); let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); - let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds); + let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds).map(|r| [r.width() as f64, r.height() as f64]); + // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). + let wh = crate::crop_ui::info_size(app).or(sel); let pos = app .hover_doc .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) @@ -1931,8 +1933,8 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Measured from the rulers' zero point (#2955). row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); - row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); - row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); + row(&mut cols[1], "W:", wh.map_or_else(dash, |s| fx(s[0]))); + row(&mut cols[1], "H:", wh.map_or_else(dash, |s| fy(s[1]))); }); widgets::hairline(ui); ui.label( From 78356601d504482fa21be88811bcbcdf11cdd13e Mon Sep 17 00:00:00 2001 From: Cristi Radu Date: Sat, 31 Oct 2026 10:14:38 +0200 Subject: [PATCH 05/42] Crop: the Info panel shows the crop frame's ratio (#3145) Under W and H, while the Crop tool shows a frame, a Ratio line gives the frame's aspect: reduced to whole numbers when both are small (4:3, 16:9, 4:5), else a decimal against 1 on the short side (1.413:1, 1:1.505), so a photo's ratio can be matched as #3145 asks. Photoshop's Info panel has no ratio line; this goes beyond it at the reporter's request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/info.rs | 11 +++++++++- crates/ui-egui/src/tools/crop_ui.rs | 25 +++++++++++++++++++++++ crates/ui-egui/src/tools/crop_ui/tests.rs | 16 +++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 42a0963ef3..0be26c9d42 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -40,7 +40,10 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds).map(|r| [r.width() as f64, r.height() as f64]); // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). - let wh = crate::crop_ui::info_size(app).or(sel); + let crop = crate::crop_ui::info_size(app); + let wh = crop.or(sel); + // And the frame's ratio, to match another photo's (#3145). + let ratio = crop.and_then(crate::crop_ui::ratio_label); let pos = hover_pixel(app); // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). @@ -79,6 +82,12 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); row(&mut cols[1], "W:", wh.map_or_else(dash, |s| fx(s[0]))); row(&mut cols[1], "H:", wh.map_or_else(dash, |s| fy(s[1]))); + if let Some(r) = ratio { + cols[1].horizontal(|ui| { + ui.label(RichText::new(format!("{}:", tl!("Ratio"))).color(t.text_dim).size(11.5)); + ui.label(RichText::new(r).font(mono.clone()).color(t.text)); + }); + } }); widgets::hairline(ui); ui.label( diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 09ee2a3584..90f4cfc8a0 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -676,6 +676,31 @@ pub fn info_size(app: &PhotocraftApp) -> Option<[f64; 2]> { frame_size(app.ui.crop_rect?) } +/// The Info panel's ratio for a frame of `size` (whole px, as [`info_size`] gives): reduced to +/// whole numbers when both are small ("4:3", "16:9", "1:1"), else as a decimal against 1 on the +/// short side ("1.413:1", "1:1.5"), so a photo's ratio can be matched (#3145). +pub fn ratio_label(size: [f64; 2]) -> Option { + let [w, h] = size; + if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) { + return None; + } + // Whole px from `frame_size`; `as` saturates for huge values and the gcd stays positive. + let (a, b) = (w.round() as u64, h.round() as u64); + let (mut x, mut y) = (a, b); + while y != 0 { + (x, y) = (y, x % y); + } + let (rw, rh) = (a / x.max(1), b / x.max(1)); + if rw <= 32 && rh <= 32 { + return Some(format!("{rw}:{rh}")); + } + let trim = |v: f64| { + let s = format!("{v:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + Some(if w >= h { format!("{}:1", trim(w / h)) } else { format!("1:{}", trim(h / w)) }) +} + /// The Crop tool owns the arrow keys and X: a pending frame and no gesture in progress. pub fn takes_keys(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_none() && app.ui.transform.is_none() && app.ui.crop_rect.is_some_and(|r| r.iter().all(|v| v.is_finite())) diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index fd086511b3..dbc55e6c14 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -1163,3 +1163,19 @@ fn info_panel_shows_the_pending_frame_size() { app.ui.tool = Tool::Move; assert_eq!(info_size(&app), None, "another tool shows the selection instead"); } + +/// #3145: the Info panel's crop ratio, reduced when small, else a decimal against 1. +#[test] +fn ratio_label_reduces_or_shows_a_decimal() { + assert_eq!(ratio_label([400.0, 300.0]).as_deref(), Some("4:3")); + assert_eq!(ratio_label([1920.0, 1080.0]).as_deref(), Some("16:9")); + assert_eq!(ratio_label([500.0, 500.0]).as_deref(), Some("1:1")); + assert_eq!(ratio_label([800.0, 1000.0]).as_deref(), Some("4:5")); + assert_eq!(ratio_label([1413.0, 1000.0]).as_deref(), Some("1.413:1")); + assert_eq!(ratio_label([200.0, 301.0]).as_deref(), Some("1:1.505")); + assert_eq!(ratio_label([1000.0, 1.0]).as_deref(), Some("1000:1")); + // Broken or absurd sizes never panic. + assert_eq!(ratio_label([f64::NAN, 1.0]), None); + assert_eq!(ratio_label([0.0, 1.0]), None); + assert!(ratio_label([f64::MAX, 1.0]).is_some()); +} From be7d68167357624421baf3add3153914ba23510d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 14:14:09 -0600 Subject: [PATCH 06/42] Free Transform: lift a pixel's coverage up to all of it, as Move does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transforming a selection split each pixel as a·k and a·(1 − k), so a selection loaded from the layer's own transparency (⌘-click) left a faint ghost of its anti-aliased edge behind. Move already lifts up to the pixel's coverage (min(a, k), leaving max(a − k, 0)); split_selected now does the same, for Free Transform, its live preview, Warp and Distort. An opaque pixel still splits as k and 1 − k. Fixes #5595 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/transform_cmds.rs | 8 +++- crates/engine/src/cmd/transform_cmds/tests.rs | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 520f61e506..3514474eaa 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -574,8 +574,12 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface for (i, (l, r)) in lp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let (x, y) = (src.x0 + (i % w) as i32, src.y0 + (i / w) as i32); let k = sel.sample_channel(x, y, 0); - l[n - 1] *= k; - r[n - 1] *= 1.0 - k; + // The selection lifts its share of a pixel's coverage, up to all of it, as Move does + // (`float_cmds::cut_piece`, #7655): a layer's own transparency loaded as the selection + // lifts it whole and leaves no ghost behind (#5595). An opaque pixel splits as k and 1 − k. + let a = l[n - 1]; + l[n - 1] = a.min(k); + r[n - 1] = (a - k).max(0.0); // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the // selected pixels' (what a stretch repeats at the edge, #3773). if l[n - 1] <= 0.0 { diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index e9ed16f245..e710ea52ed 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -907,3 +907,45 @@ fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() { assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame"); } } + +/// Free Transform of a layer's own transparency (⌘-click its thumbnail) takes all of it and leaves +/// no ghost where it was, as Photoshop does and as Move already did (#5595). Photoshop 2025, an +/// anti-aliased 60 px ellipse moved 150 px with Free Transform: no pixel left behind. +#[test] +fn transforming_a_layers_own_transparency_leaves_no_ghost() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 120, "background": "transparent"})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 60, "height": 60, "ellipse": true, "antiAlias": true})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#cc2222"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + let alpha_sum = |s: &Session, area: Rect| -> f32 { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(area).as_chunks::<4>().0.iter().map(|p| p[3]).sum() + }; + let (old, new) = (Rect::new(0, 0, 110, 120), Rect::new(110, 0, 300, 120)); + let before = alpha_sum(&s, old); + assert!(before > 2000.0, "the ellipse: {before}"); + s.execute("edit.transform", json!({"dx": 150, "dy": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let left = surf.read_region(old).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.0).count(); + assert_eq!(left, 0, "pixels left behind at the old spot"); + let moved = alpha_sum(&s, new); + assert!((moved - before).abs() < 1.0, "all the coverage moved: {before} → {moved}"); + // An opaque pixel half selected still splits half and half. + let mut s = session(); + s.edit("half", |doc, _| { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(10, 10, 30, 20), &[0.5]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"dx": 40, "dy": 0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let rest = surf.read_region(Rect::new(15, 15, 16, 16)); + assert!((rest[3] - 0.5).abs() < 0.01, "half the opaque pixel stays: {rest:?}"); +} From e5f810f2bbb5c8cd84b18275e2c84379089830ed Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:52:15 -0600 Subject: [PATCH 07/42] =?UTF-8?q?Tests:=20split=5Fselected=20lifts=20min(a?= =?UTF-8?q?,=20k)=20and=20leaves=20max(a=20=E2=88=92=20k,=200)=20(FredApps?= =?UTF-8?q?=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/transform_cmds/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index e710ea52ed..8c40131fdf 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -949,3 +949,22 @@ fn transforming_a_layers_own_transparency_leaves_no_ghost() { let rest = surf.read_region(Rect::new(15, 15, 16, 16)); assert!((rest[3] - 0.5).abs() < 0.01, "half the opaque pixel stays: {rest:?}"); } + +#[test] +fn split_selected_lifts_up_to_the_selection_and_leaves_the_rest() { + // One half-covered pixel under a partial selection: the lift takes min(a, k), the rest keeps + // max(a − k, 0), and the two add back up to the pixel's coverage. + let gray_f32 = PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false); + let a = 0.5_f32; + for k in [0.25_f32, 0.5, 0.75] { + let mut surf = Surface::new(PixelFormat::RGBA32F); + surf.fill_rect(Rect::new(3, 4, 4, 5), &[1.0, 0.0, 0.0, a]); + let mut sel = Surface::new(gray_f32); + sel.fill_rect(Rect::new(3, 4, 4, 5), &[k]); + let (lifted, rest) = split_selected(&surf, &sel).unwrap(); + let (l, r) = (lifted.sample_channel(3, 4, 3), rest.sample_channel(3, 4, 3)); + assert!((l - a.min(k)).abs() < 1e-6, "k = {k}: lifted {l}, want {}", a.min(k)); + assert!((r - (a - k).max(0.0)).abs() < 1e-6, "k = {k}: left {r}, want {}", (a - k).max(0.0)); + assert!((l + r - a).abs() < 1e-6, "k = {k}: lifted {l} + left {r} != {a}"); + } +} From 2a7ab4aa98f70bb307bf34cd39124bacf9077bae Mon Sep 17 00:00:00 2001 From: Cristi Radu Date: Sun, 1 Nov 2026 07:46:58 +0200 Subject: [PATCH 08/42] Crop: test the Info panel's crop size, units and ratio (#3145) Review follow-up: W and H share one formatter (wh_readout) for the selection and the crop frame; tests cover the ratio's rounding and degenerate frames, W and H in each ruler unit, and W and H after the crop is cancelled, committed or the tool is left. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/panels/info.rs | 12 +++- crates/ui-egui/src/tools/crop_ui/tests.rs | 69 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index be80f832bb..b1b787ac33 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -33,7 +33,7 @@ mod dock; pub mod histogram_panel; mod history; pub mod history_menu_ui; -mod info; +pub(crate) mod info; mod layer_drag; pub mod layer_filter_ui; pub mod layer_menu_ui; diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 0be26c9d42..f3a6572081 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -23,6 +23,12 @@ pub(crate) fn pointer_sample(app: &mut PhotocraftApp, (x, y): (i32, i32)) -> Opt Some(v) } +/// The Info panel's W and H for a size of `wh` px (the selection's or the pending crop frame's): +/// both go through the ruler unit's formatter at `dpi`, percent of the document's `extent`. +pub(crate) fn wh_readout(ur: &photocraft_engine::prefs::UnitsAndRulers, dpi: f64, extent: [f64; 2], wh: [f64; 2]) -> [String; 2] { + [ur.format(wh[0], dpi, extent[0]), ur.format(wh[1], dpi, extent[1])] +} + /// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size. pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -37,6 +43,7 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let dpi = st.doc.resolution_dpi as f64; let fx = move |px: f64| units.format(px, dpi, size.width as f64); let units_y = app.session.prefs().units_and_rulers.clone(); + let units_wh = app.session.prefs().units_and_rulers.clone(); let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds).map(|r| [r.width() as f64, r.height() as f64]); // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). @@ -80,8 +87,9 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Measured from the rulers' zero point (#2955). row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); - row(&mut cols[1], "W:", wh.map_or_else(dash, |s| fx(s[0]))); - row(&mut cols[1], "H:", wh.map_or_else(dash, |s| fy(s[1]))); + let [w, h] = wh.map_or_else(|| [dash(), dash()], |s| wh_readout(&units_wh, dpi, [size.width as f64, size.height as f64], s)); + row(&mut cols[1], "W:", w); + row(&mut cols[1], "H:", h); if let Some(r) = ratio { cols[1].horizontal(|ui| { ui.label(RichText::new(format!("{}:", tl!("Ratio"))).color(t.text_dim).size(11.5)); diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index dbc55e6c14..c066aed788 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -1179,3 +1179,72 @@ fn ratio_label_reduces_or_shows_a_decimal() { assert_eq!(ratio_label([0.0, 1.0]), None); assert!(ratio_label([f64::MAX, 1.0]).is_some()); } + +/// #3145 review: the ratio of a frame that is 16:9 only after rounding to whole px, of a frame +/// near 16:9 but not on it, and of degenerate frames (under a pixel, or a line). +#[test] +fn ratio_label_rounds_and_rejects_degenerate_frames() { + assert_eq!(ratio_label([1920.4, 1079.6]).as_deref(), Some("16:9"), "rounded to whole px first"); + assert_eq!(ratio_label([1366.0, 768.0]).as_deref(), Some("1.779:1"), "near 16:9 is not 16:9"); + assert_eq!(ratio_label([1.0, 1.0]).as_deref(), Some("1:1")); + assert_eq!(ratio_label([0.4, 100.0]), None, "under a pixel wide"); + assert_eq!(ratio_label([100.0, 0.0]), None, "a line"); + assert_eq!(ratio_label([-16.0, -9.0]), None, "negative"); + assert_eq!(ratio_label([f64::INFINITY, 9.0]), None); + // A frame `frame_size` floors to 1 px stays labelled, never a division by zero. + let tiny = frame_size([0.0, 0.0, 0.2, 0.4]).unwrap(); + assert_eq!(ratio_label(tiny).as_deref(), Some("1:1")); +} + +/// #3145 review: the crop frame's W and H use the Info panel's formatter for the ruler unit, +/// the same one the selection's go through. +#[test] +fn info_size_is_shown_in_the_ruler_unit() { + use crate::panels::info::wh_readout; + use photocraft_engine::prefs::{Unit, UnitsAndRulers}; + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([0.0, 0.0, 144.0, 72.0]); + let wh = info_size(&app).unwrap(); + let mut ur = UnitsAndRulers::default(); + let extent = [200.0, 100.0]; + ur.rulers = Unit::Pixels; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["144", "72"]); + ur.rulers = Unit::Inches; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["2", "1"]); + assert_eq!(wh_readout(&ur, 144.0, extent, wh), ["1", "0.5"], "at the document's resolution"); + ur.rulers = Unit::Centimeters; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["5.08", "2.54"]); + ur.rulers = Unit::Percent; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["72", "72"], "W of the width, H of the height"); +} + +/// #3145 review: after the crop is cancelled or committed, or the tool is left, W and H are the +/// selection's again. With the Crop tool still picked, the new default frame is the selection's +/// bounds (or the canvas), as in Photoshop, so the panel shows the same size. +#[test] +fn info_size_returns_to_the_selection_after_cancel_or_commit() { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 20, "y": 10, "width": 80, "height": 40})).unwrap(); + // Cancel: no frame, then the new default frame frames the selection. + ensure_frame(&mut app); + app.ui.crop_rect = Some([10.0, 10.0, 50.0, 40.0]); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + cancel(&mut app); + assert_eq!(info_size(&app), None, "cancelled: the panel falls back to the selection"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), Some([80.0, 40.0]), "the new frame is the selection's bounds"); + // Commit: the document is cropped and the frame is gone. + app.run("select.deselect", json!({})).unwrap(); + cancel(&mut app); + drag(&mut app, &[[10.0, 10.0], [50.0, 40.0]], NONE); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(40, 30)); + assert_eq!(info_size(&app), None, "committed: no frame"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), Some([40.0, 30.0]), "the new frame is the cropped canvas"); + // Leaving the tool: the panel shows the selection, whatever the frame. + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + assert_eq!(info_size(&app), None); +} From 0125925ee39723c6d61a54e67db961161982623e Mon Sep 17 00:00:00 2001 From: D-monized <341904702+D-monized@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:51:10 -0600 Subject: [PATCH 09/42] Crop: no frame around the cropped image until the next press After a committed crop the Crop tool drew a new default frame with corner handles around the cropped canvas at once, so the crop looked like it never applied. A successful commit now hides the frame for the cropped document until a press on the canvas, re-picking the tool, or another document (or size of this one), keyed by document id and size as in #10396. Co-Authored-By: Claude Opus 5.5 --- .../src/canvas/canvas_tool_menu/tests.rs | 32 +++++++++++++++++++ crates/ui-egui/src/canvas/gesture.rs | 1 + crates/ui-egui/src/tools/crop_mode/tests.rs | 4 +++ crates/ui-egui/src/tools/crop_ui.rs | 30 +++++++++++++++++ crates/ui-egui/src/tools/crop_ui/tests.rs | 12 +++++-- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 6f8d0a3aa8..486088ec2d 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -993,6 +993,38 @@ fn right_click_on_a_pending_crop_opens_the_crop_menu_and_a_ratio_refits_the_fram assert!(h.state().ui.canvas_tool_menu.is_none()); } +/// The menu's Crop row, clicked with the mouse, crops; the Crop tool then shows no frame until +/// the next press, so the applied crop doesn't look still pending. +#[test] +fn the_crop_row_crops_and_no_frame_shows_until_the_next_press() { + let mut app = crop_app(); + app.ui.tool_options.crop_shield.classic_mode = false; + let mut h = harness(app); + // Tall enough for the whole menu, down to its last row. + h.set_size(vec2(1000.0, 1400.0)); + draw_crop(h.state_mut()); + h.run_steps(2); + let r = h.state().ui.crop_rect.unwrap(); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + h.get_by_label("Crop").click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.size; + assert_eq!((f64::from(size.width), f64::from(size.height)), ((r[2] - r[0]).round(), (r[3] - r[1]).round()), "cropped to the frame"); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().ui.tool, Tool::Crop); + assert_eq!(h.state().ui.crop_rect, None, "no frame around the cropped image"); + // A click on the canvas brings back the tool's default frame. + let center = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.crop_rect.is_some() && h.state().crop.default_frame, "the press brings a frame back"); +} + #[test] fn untouched_crop_frame_has_no_menu_and_a_pending_one_wins_over_the_pasteboard() { let mut h = harness(crop_app()); diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index 6e4e015d9f..ae89b9063f 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -293,5 +293,6 @@ pub fn commit_crop(app: &mut PhotocraftApp) { crate::crop_ui::restore(app, kept); } else if let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + crate::crop_ui::hide_frame(app); } } diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 5f059917c9..afe7f580fc 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -716,6 +716,10 @@ fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() { let v = &mut app.ui.views[0]; (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0); app.ui.extras.snap = true; + // Re-picking the tool brings back the default frame the commit hid. + app.ui.tool = Tool::Move; + crate::crop_ui::ensure_frame(&mut app); + app.ui.tool = Tool::Crop; crate::crop_ui::ensure_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); if ratio { diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..5e6625a2ed 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -51,6 +51,10 @@ pub struct CropState { pub default_frame: bool, /// The document (index and size) the default frame was made for. frame_for: Option<(usize, u32, u32)>, + /// The document (id and size) whose frame [`hide_frame`] hid: no default frame is made for it + /// while this matches. Keyed by id, not tab index, so closing it never hides the frame of the + /// tab that takes its place. + dismissed: Option<(photocraft_doc::DocId, u32, u32)>, /// The frame is being edited (pressed since it was made): the canvas shows what lies past it. pub editing: bool, /// The document the pending frame (or gesture) belongs to ([`cancel_stale`], #1918). @@ -350,6 +354,7 @@ pub fn ensure_frame(app: &mut PhotocraftApp) { fn frame(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop { app.crop.editing = false; + app.crop.dismissed = None; crate::crop_straighten::reset(app); // Leaving the tool drops an untouched default frame and, as in Photoshop, commits a // drawn one (#3368). @@ -377,6 +382,11 @@ fn frame(app: &mut PhotocraftApp) { if app.ui.crop_rect.is_some() || app.crop.drag.is_some() { return; } + // A committed crop hid the frame: it stays hidden for this document until the next press. + if app.crop.dismissed.is_some() && app.crop.dismissed == dismiss_key(app) { + return; + } + app.crop.dismissed = None; let Some(st) = app.session.active() else { return }; let canvas = st.doc.bounds(); // Computed once per new frame, not per frame drawn. @@ -433,6 +443,20 @@ pub fn cancel(app: &mut PhotocraftApp) { crate::crop_straighten::reset(app); } +/// After a committed crop, with the Crop tool picked: no new default frame for the cropped +/// document until a press on the canvas or re-picking the tool, so its corners don't make the +/// applied crop look still pending. +pub fn hide_frame(app: &mut PhotocraftApp) { + if app.ui.tool == Tool::Crop { + app.crop.dismissed = dismiss_key(app); + } +} + +/// The active document's identity for [`CropState::dismissed`]. +fn dismiss_key(app: &PhotocraftApp) -> Option<(photocraft_doc::DocId, u32, u32)> { + app.session.active().map(|st| (st.doc.id, st.doc.size.width, st.doc.size.height)) +} + /// Drop the box's own Undo/Redo (leaving the tool, cancelling or committing a crop). pub fn clear_history(app: &mut PhotocraftApp) { app.crop.undo.clear(); @@ -796,6 +820,11 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; if !crate::crop_straighten::pointer(app, ev, mods) { return false; } + // A press it takes brings the hidden frame back like any other press, even when the line is + // too short to turn the frame. + if matches!(ev, ToolEvent::Down { .. }) { + app.crop.dismissed = None; + } claim(app); // Default mode: the image turns so the line ends up level. // The turn is one box Undo step, like any other frame gesture. @@ -815,6 +844,7 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; /// Starts the drag a press at `p` selects: resize, move, turn or draw. fn begin_drag(app: &mut PhotocraftApp, p: [f64; 2]) { + app.crop.dismissed = None; claim(app); app.crop.editing = true; app.crop.press_step = Some(step_of(app)); diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 6fba4e57d4..babe3fe85a 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -122,7 +122,13 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { assert_eq!(doc.size, Size::new(30, 40), "{depth:?}"); assert_eq!(doc.depth, depth); assert!(doc.selection.is_none(), "cropping to the selection deselects, as in Photoshop"); - // The new default frame is the whole (cropped) canvas: ↵ on it does nothing. + // No frame until a press or re-picking the tool; then the default one is the whole + // (cropped) canvas, and ↵ on it does nothing. + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + app.ui.tool = Tool::Crop; ensure_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 30.0, 40.0])); let steps = app.session.active().unwrap().history.past_len(); @@ -152,7 +158,7 @@ fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { assert!(shows_beyond_canvas(&app)); crate::canvas::commit_crop(&mut app); ensure_frame(&mut app); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 50.0, 30.0])); + assert_eq!(app.ui.crop_rect, None, "no frame until the next press"); assert!(!shows_beyond_canvas(&app)); // Or until another tool is picked. drag(&mut app, &[[5.0, 5.0], [20.0, 20.0]], NONE); @@ -851,7 +857,7 @@ fn enter_during_a_drag_ends_the_drag() { tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); ensure_frame(&mut app); assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert_eq!(app.ui.crop_rect, None); assert!(!pending(&app)); } From ea8ba0b3152ebfe022829fd5af81c66458c01ccd Mon Sep 17 00:00:00 2001 From: D-monized <341904702+D-monized@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:03:40 -0600 Subject: [PATCH 10/42] Crop: a hidden frame comes back on re-picking, a ratio or a press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. show_frame ends the hidden state and makes the default frame at once, so: - picking the Crop tool, even while it is picked, shows the frame; - a ratio or size chosen after a commit fits a frame, so ✓ crops; - a press records the default frame as its box Undo step, so Undo then Redo of the first crop after a commit keeps the drawn frame. Co-Authored-By: Claude Opus 5.5 --- .../src/canvas/canvas_tool_menu/tests.rs | 4 +-- crates/ui-egui/src/tool_memory.rs | 4 +++ crates/ui-egui/src/tools/crop_ui.rs | 14 ++++++-- crates/ui-egui/src/tools/crop_ui/tests.rs | 35 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 486088ec2d..4de185a412 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -1014,7 +1014,7 @@ fn the_crop_row_crops_and_no_frame_shows_until_the_next_press() { assert!(h.state().ui.canvas_tool_menu.is_none()); assert_eq!(h.state().ui.tool, Tool::Crop); assert_eq!(h.state().ui.crop_rect, None, "no frame around the cropped image"); - // A click on the canvas brings back the tool's default frame. + // A click on the canvas brings back a frame around the whole cropped image. let center = h.state().last_canvas_rect.center(); h.event(egui::Event::PointerMoved(center)); h.run_steps(1); @@ -1022,7 +1022,7 @@ fn the_crop_row_crops_and_no_frame_shows_until_the_next_press() { h.run_steps(1); h.event(egui::Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); - assert!(h.state().ui.crop_rect.is_some() && h.state().crop.default_frame, "the press brings a frame back"); + assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, f64::from(size.width), f64::from(size.height)]), "the click brings a frame back"); } #[test] diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 2b05c523d6..1464052aad 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -26,6 +26,10 @@ pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; app.ui.spring = None; app.ui.tool = tool; + // Picking the Crop tool, even the one already picked, brings back a frame a commit hid. + if tool == Tool::Crop { + crate::crop_ui::show_frame(app); + } } /// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 5e6625a2ed..a59e9ca019 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -248,6 +248,8 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() { return; } + // A ratio or size chosen after a commit fits a frame, not nothing. + show_frame(app); frame(app); fit_frame(app); crate::crop_mode::sync_frame(app); @@ -452,6 +454,14 @@ pub fn hide_frame(app: &mut PhotocraftApp) { } } +/// Ends [`hide_frame`] and makes the default frame now, so what follows acts on it as on any +/// default frame: a press records it as the box's Undo step, a ratio fits it. +pub(crate) fn show_frame(app: &mut PhotocraftApp) { + if app.crop.dismissed.take().is_some() { + frame(app); + } +} + /// The active document's identity for [`CropState::dismissed`]. fn dismiss_key(app: &PhotocraftApp) -> Option<(photocraft_doc::DocId, u32, u32)> { app.session.active().map(|st| (st.doc.id, st.doc.size.width, st.doc.size.height)) @@ -823,7 +833,7 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; // A press it takes brings the hidden frame back like any other press, even when the line is // too short to turn the frame. if matches!(ev, ToolEvent::Down { .. }) { - app.crop.dismissed = None; + show_frame(app); } claim(app); // Default mode: the image turns so the line ends up level. @@ -844,7 +854,7 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; /// Starts the drag a press at `p` selects: resize, move, turn or draw. fn begin_drag(app: &mut PhotocraftApp, p: [f64; 2]) { - app.crop.dismissed = None; + show_frame(app); claim(app); app.crop.editing = true; app.crop.press_step = Some(step_of(app)); diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index babe3fe85a..ea456ab6a4 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -138,6 +138,41 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { } } +/// A commit hides the frame; picking the Crop tool again, a ratio, or a press brings back the +/// default frame, and the press's crop keeps its box Undo and Redo. +#[test] +fn a_hidden_frame_comes_back_on_re_picking_a_ratio_or_a_press_with_redo_intact() { + let committed = || { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 60.0]); + crate::canvas::commit_crop(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "hidden after the commit"); + app + }; + let whole = Some([0.0, 0.0, 100.0, 50.0]); + // Picking the Crop tool while it is still picked. + let mut app = committed(); + crate::tool_memory::select(&mut app, Tool::Crop); + assert_eq!(app.ui.crop_rect, whole); + assert!(app.crop.default_frame); + // A ratio fits the frame it brings back. + let mut app = committed(); + app.ui.tool_options.crop_ratio = "1:1".into(); + apply_ratio(&mut app); + assert_eq!(app.ui.crop_rect, Some([25.0, 0.0, 75.0, 50.0])); + // A drawn crop undoes to the default frame, and redoes. + let mut app = committed(); + drag(&mut app, &[[10.0, 10.0], [40.0, 30.0]], NONE); + let drawn = app.ui.crop_rect; + assert_eq!(drawn, Some([10.0, 10.0, 40.0, 30.0])); + assert!(intercept(&mut app, "edit.undo").is_some()); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, whole, "undo goes back to the default frame"); + assert!(intercept(&mut app, "edit.redo").is_some()); + assert_eq!(app.ui.crop_rect, drawn, "and redo still has the drawn frame"); +} + #[test] fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { let mut app = app(SampleType::U8); From 0a93d46ac1e5611f57c0ffa53773e2dd618f4220 Mon Sep 17 00:00:00 2001 From: vngocvan Date: Sun, 1 Nov 2026 17:21:53 +0700 Subject: [PATCH 11/42] fix(ui): clear custom crop ratio fields explicitly --- crates/ui-egui/src/tools/crop_size.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index 190ca1bf33..c9ce39f83d 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -232,7 +232,8 @@ pub fn clear_ratio(o: &mut ToolOptions) { clear(o); } else { o.crop_ratio.clear(); - select_custom_ratio(o); + o.crop_custom_ratio_width = 0.0; + o.crop_custom_ratio_height = 0.0; } } From 7f5e103ff1e9fd02fe700f3c04a6df4a90152129 Mon Sep 17 00:00:00 2001 From: jayzed80 Date: Mon, 2 Nov 2026 12:49:55 +0000 Subject: [PATCH 12/42] fix(algo): Filter Gallery reach no longer overflows on out-of-range stored effect values --- crates/algo/src/artistic.rs | 13 ++++++ crates/algo/src/artistic_fx.rs | 4 +- crates/algo/tests/it/gallery_reach_extreme.rs | 43 +++++++++++++++++++ crates/algo/tests/it/main.rs | 1 + 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/algo/tests/it/gallery_reach_extreme.rs diff --git a/crates/algo/src/artistic.rs b/crates/algo/src/artistic.rs index 4266091451..cb817f2935 100644 --- a/crates/algo/src/artistic.rs +++ b/crates/algo/src/artistic.rs @@ -236,6 +236,19 @@ impl GalleryEffect { } } + /// A copy whose stored numeric values are held to their declared ranges (a non-finite value + /// becomes the default). A project file stores `values` as plain numbers, so unlike + /// [`Self::set`] nothing has clamped them; geometry sized from them (reach) needs it. + pub(crate) fn clamped(&self) -> GalleryEffect { + let mut out = self.clone(); + for p in self.filter.params() { + if let (GalleryParamKind::Range { min, max, default }, Some(v)) = (&p.kind, out.values.get_mut(p.key)) { + *v = if v.is_finite() { v.clamp(*min, *max) } else { *default }; + } + } + out + } + /// A bool value. pub fn flag(&self, key: &str) -> bool { self.get(key) != 0.0 diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 6ef9dad13a..6bda692017 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -407,6 +407,8 @@ fn highlights(p: Px, l: f32, area: f32, intensity: f32) -> Px { /// Pixels an effect reads around an output pixel. pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { + // Stored values come from a project file unclamped; keep the arithmetic below inside i32. + let e = &e.clamped(); let g = |k: &str| e.get(k); match e.filter { F::ColoredPencil => 2, @@ -528,7 +530,7 @@ fn glass_cell(e: &GalleryEffect) -> f32 { /// Total reach of a stack of effects. pub(crate) fn reach(effects: &[GalleryEffect]) -> i32 { - effects.iter().map(effect_reach).sum() + effects.iter().map(effect_reach).fold(0, i32::saturating_add) } /// Runs a stack of effects over `out` (the source covers `out` grown by [`reach`]). diff --git a/crates/algo/tests/it/gallery_reach_extreme.rs b/crates/algo/tests/it/gallery_reach_extreme.rs new file mode 100644 index 0000000000..4e52dc3501 --- /dev/null +++ b/crates/algo/tests/it/gallery_reach_extreme.rs @@ -0,0 +1,43 @@ +//! A project file stores a Filter Gallery effect's values as plain numbers, bypassing the clamp that +//! `GalleryEffect::set` applies, so the reach arithmetic must survive any `f32` in them. + +use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo}; + +const EXTREME: [f32; 6] = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, 3.0e9]; + +/// The reach in pixels; a whole-image effect (`Halo::Bounds`) has no radius to check. +fn radius(effects: Vec) -> i32 { + match (FilterParams::FilterGallery { effects }).halo() { + Halo::Radius(r) => r, + _ => 0, + } +} + +fn with_all_ranges(filter: GalleryFilter, v: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if matches!(p.kind, GalleryParamKind::Range { .. }) { + e.values.insert(p.key.to_string(), v); + } + } + e +} + +#[test] +fn gallery_reach_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + for v in EXTREME { + let r = radius(vec![with_all_ranges(filter, v)]); + assert!(r >= 0, "{filter:?} with every range at {v}: reach {r}"); + } + } +} + +#[test] +fn gallery_reach_of_a_stack_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + let stack = vec![with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX)]; + let r = radius(stack); + assert!(r >= 0, "{filter:?} stacked three times: reach {r}"); + } +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index 1f26e46b8e..342eeb2501 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod gallery_reach_extreme; mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; From cf651b7c2e1887142bd4ed6d9319ce4479ddc022 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:26:02 +0100 Subject: [PATCH 13/42] Crop: a Ctrl-held Straighten line keeps the frame size (#10870) A Straighten line drawn with Ctrl (Cmd) held now turns the frame by the line's angle and leaves its size and position alone, so the commit fills the corners the turn uncovers, as the reference application does. The options-bar Straighten button keeps fitting the frame inside the turned image. --- crates/ui-egui/src/tools/crop_straighten.rs | 29 ++++-- .../ui-egui/tests/it/crop_ctrl_straighten.rs | 90 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/crop_ctrl_straighten.rs diff --git a/crates/ui-egui/src/tools/crop_straighten.rs b/crates/ui-egui/src/tools/crop_straighten.rs index 5f663f2e6b..d26b111104 100644 --- a/crates/ui-egui/src/tools/crop_straighten.rs +++ b/crates/ui-egui/src/tools/crop_straighten.rs @@ -8,8 +8,10 @@ //! - Holding ⌘ (Ctrl off the Mac) with the Crop tool is a temporary Straighten: a ⌘-drag draws the //! line; releasing ⌘ gives the normal crop gestures back. //! -//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle -//! and fits the frame to the image ([`fitted`]); in Classic Mode the frame then shows turned over +//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle. +//! The button's line also fits the frame to the image ([`fitted`]); a ⌘-held line keeps the +//! frame's size and position, so ↵ fills the corners the turn uncovers, as Photoshop does +//! (measured: an 800 × 500 frame stays 800 × 500, #10870). In Classic Mode the frame then shows turned over //! the image, in the default mode the image turns behind the upright box (`crop_mode`). It is only a pending frame edit: //! ↵ commits it and Esc cancels it as any crop. A line shorter than [`MIN_LINE_PX`] screen points //! does nothing. @@ -87,10 +89,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => { let Some([a, _]) = app.crop.straighten.line.take() else { return false }; - // One line per click of the button. - app.crop.straighten.armed = false; + // One line per click of the button. Only the button's line fits the frame: a line + // drawn with ⌘ held keeps the frame's size. + let fit = std::mem::take(&mut app.crop.straighten.armed); let zoom = f64::from(app.point_zoom()); - apply(app, a, [x, y], zoom); + apply(app, a, [x, y], zoom, fit); true } } @@ -129,15 +132,23 @@ pub fn fitted(w: f64, h: f64, cw: f64, ch: f64, deg: f64) -> Option<[f64; 4]> { Some([cx - hw, cy - hh, cx + hw, cy + hh]) } -/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame. -fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64) { +/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame, +/// fitting it to the image when `fit` (the options-bar button) and keeping its size and position +/// otherwise (⌘ held). +fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64, fit: bool) { let min_len = if zoom.is_finite() && zoom > 0.0 { MIN_LINE_PX / zoom } else { MIN_LINE_PX }; let Some(deg) = frame_angle(a, b, min_len) else { return }; let Some(size) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame // takes the canvas's. - let (w, h) = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())).map_or(size, |r| (r[2] - r[0], r[3] - r[1])); - let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())); + let r = if fit { + let (w, h) = frame.map_or(size, |r| (r[2] - r[0], r[3] - r[1])); + let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + r + } else { + frame.unwrap_or([0.0, 0.0, size.0, size.1]) + }; if app.ui.crop_rect == Some(r) && crate::crop_ui::angle(app) == deg { return; } diff --git a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs new file mode 100644 index 0000000000..0e888e1307 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -0,0 +1,90 @@ +//! Crop tool: a Straighten line drawn with ⌘ (Ctrl) held turns the image but keeps the frame's +//! size (#10870), while the options-bar Straighten button keeps fitting the frame inside the +//! turned image. +//! +//! Measured in the reference application: an 800 × 500 document, the frame around the whole +//! canvas, a line rising about 10°. The button commits 633 × 396; ⌘ held commits 800 × 500 with +//! the uncovered corners in the background colour (Fill: Background). PhotoCraft fitted the frame +//! both ways. +//! +//! Driven as an agent does, through `ui.pointer` (document px), in both crop modes. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use serde_json::{Value, json}; + +const BLUE: [f32; 3] = [16.0 / 255.0, 80.0 / 255.0, 224.0 / 255.0]; + +fn app(classic: bool) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 800, "height": 500, "background": "white"})).expect("new"); + app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool_options.crop_shield.classic_mode = classic; + control(&mut app, &ctx, "ui.set", json!({"tool": "crop"})); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "the default frame"); + (app, ctx) +} + +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); +} + +/// A line rising 10° to the right across the canvas, `command` held or not. +fn line(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool) { + let (a, b) = ([100.0, 300.0], [700.0, 300.0 - 600.0 * 10.0f64.to_radians().tan()]); + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + let events = json!([ + {"kind": "down", "x": a[0], "y": a[1]}, + {"kind": "move", "x": mid[0], "y": mid[1]}, + {"kind": "move", "x": b[0], "y": b[1]}, + {"kind": "up", "x": b[0], "y": b[1]}, + ]); + control(app, ctx, "ui.pointer", json!({"command": command, "events": events})); +} + +fn committed(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("document").doc.size; + (size.width, size.height) +} + +#[test] +fn a_command_held_straighten_line_keeps_the_frame_size() { + for classic in [false, true] { + let (mut app, ctx) = app(classic); + line(&mut app, &ctx, true); + // The frame turns (counter-clockwise: the line rises to the right) and stays put. + assert!((photocraft_ui_egui::crop_ui::angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}"); + assert!(photocraft_ui_egui::crop_ui::pending(&app), "classic {classic}: a pending edit"); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + // The corners the turn uncovered take the background colour; the middle is the image. + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + let at = |x: usize, y: usize| flat.px[y * 800 + x]; + for (x, y) in [(0, 0), (799, 0), (0, 499), (799, 499)] { + let p = at(x, y); + assert!(BLUE.iter().zip(p).all(|(w, g)| (w - g).abs() < 0.02), "classic {classic}: corner ({x}, {y}) {p:?}"); + } + assert!(at(400, 250)[..3].iter().all(|&v| v > 0.98), "classic {classic}: the white image in the middle"); + } +} + +#[test] +fn the_straighten_button_still_fits_the_frame() { + for classic in [false, true] { + let (mut app, ctx) = app(classic); + photocraft_ui_egui::crop_straighten::toggle(&mut app); + line(&mut app, &ctx, false); + assert!((photocraft_ui_egui::crop_ui::angle(&app) + 10.0).abs() < 1e-6, "classic {classic}"); + assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "classic {classic}: one line per click"); + let (w, h) = committed(&mut app); + assert!(w.abs_diff(633) <= 1 && h.abs_diff(396) <= 1, "classic {classic}: {w} × {h}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..7ea5e2b628 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -12,6 +12,7 @@ mod checker_canvas; mod clipboard_svg; mod color_managed_canvas; mod crop_beyond_canvas; +mod crop_ctrl_straighten; mod crop_default_mode; mod crop_popup_keys; mod custom_print_paper; From c9fc8458b47630acebe76b84307e7de9ff442aa1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 16:40:07 -0800 Subject: [PATCH 14/42] Map recorded Shear actions with measured curve settings --- crates/engine/src/cmd/atn_cmds/events.rs | 3 +- .../engine/src/cmd/atn_cmds/steps_distort.rs | 93 +++++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 167 ++++++++++++++++++ docs/atn.md | 11 +- docs/file-format-parity.md | 5 +- 5 files changed, 274 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index a29b4a1957..82c59245eb 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -4,7 +4,7 @@ use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to}; -use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to}; +use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to, @@ -382,6 +382,7 @@ pub(super) const EVENTS: &[Event] = &[ &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), // Distort (#10605): every key in recorded order, Photoshop's limits and defaults. + Event { code: "Shr ", name: "shear", label: "Shear", ids: &["filter.distort.shear"], from: shear_from, to: shear_to }, Event { code: "Pnch", name: "pinch", diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 86ffa239da..b34702a572 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -1,4 +1,4 @@ -//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and +//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave and //! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. use super::descriptors::{desc, enm, num, trimmed}; @@ -219,3 +219,94 @@ pub(super) fn wave_to(_: &str, p: &Value) -> Option { }; Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed))) } + +/// A curve must retain its order and points when the native renderer sorts and deduplicates it. +fn shear_curve(points: &[[f64; 2]]) -> bool { + points.len() >= 2 + && points.iter().all(|p| p[0].is_finite() && (0.0..=1.0).contains(&p[0]) && (-1.0..=63.0 / 64.0).contains(&p[1])) + && points.windows(2).all(|p| p[1][0] as f32 - p[0][0] as f32 >= 1e-6) +} + +/// Recorded grid coordinates become native top-to-bottom positions and half-width offsets. +fn shear_normalized(points: &[[f64; 2]]) -> Vec<[f64; 2]> { + points.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect() +} + +/// Read every measured point; an invalid entry must not disappear in the native list parser. +fn shear_points(value: &D) -> Option> { + let D::List(values) = value else { return None }; + values + .iter() + .map(|v| { + let D::Descriptor(point) = v else { return None }; + if trimmed(&point.class_id) != "Pnt" { + return None; + } + let [(h, D::Double(x)), (v, D::Double(y))] = point.items.as_slice() else { return None }; + (trimmed(h) == "Hrzn" && trimmed(v) == "Vrtc" && x.is_finite() && y.is_finite()).then(|| [x.clamp(-64.0, 63.0), y.clamp(1.0, 128.0)]) + }) + .collect() +} + +/// Import only the complete measured curve range, with dialog clamps and exact fractional save data. +pub(super) fn shear_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !only_keys(d, &["ShrP", "UndA", "ShrS", "ShrE"]) { + return None; + } + let grid = setting(d, "ShrP", shear_points, vec![[0.0, 1.0], [0.0, 128.0]])?; + let index = |v: &D| if let D::Integer(n) = v { Some(*n) } else { None }; + if setting(d, "ShrS", index, 0)? != 0 || setting(d, "ShrE", index, 1)? != i32::try_from(grid.len().checked_sub(1)?).ok()? { + return None; + } + let points = shear_normalized(&grid); + if !shear_curve(&points) { + return None; + } + let undefined = setting(d, "UndA", enum_of("UndA"), "WrpA".into())?; + let mut p = json!({"points": points, "undefinedAreas": lookup(WAVE_UNDEFINED, &undefined)?}); + // Dividing by 127 can lose a wire bit (for example Vrtc = 4.975609756097561). + // Preserve only that case, and reject stale metadata on export after a point edit. + if points.iter().zip(&grid).any(|(p, g)| [p[1] * 64.0, p[0] * 127.0 + 1.0] != *g) { + p["_atnShearPoints"] = json!(grid); + } + Some(step("filter.distort.shear", p)) + }; + mapped().unwrap_or(Mapped::Unsupported { harmless: false }) +} + +/// Export the effective native curve; settings without a faithful measured wire form are refused. +pub(super) fn shear_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["points", "amount", "undefinedAreas", "target", "_atnShearPoints"]) { + return None; + } + let points: Vec<[f64; 2]> = match p.get("points") { + None | Some(Value::Null) => { + let amount = match p.get("amount") { + None | Some(Value::Null) => 0.0, + Some(value) => value.as_f64().filter(|n| n.is_finite())?, + }; + // Match the native command's f32 conversion and amount clamp. + let amount = (amount as f32).clamp(-100.0, 100.0) / 100.0; + vec![[0.0, 0.0], [0.5, f64::from(amount)], [1.0, 0.0]] + } + Some(value) => serde_json::from_value(value.clone()).ok()?, + }; + if !shear_curve(&points) { + return None; + } + let grid: Vec<[f64; 2]> = match p.get("_atnShearPoints") { + None => points.iter().map(|p| [p[1] * 64.0, p[0] * 127.0 + 1.0]).collect(), + Some(value) => serde_json::from_value(value.clone()).ok()?, + }; + if grid.iter().any(|p| !(-64.0..=63.0).contains(&p[0]) || !(1.0..=128.0).contains(&p[1])) || shear_normalized(&grid) != points { + return None; + } + let end = i32::try_from(grid.len().checked_sub(1)?).ok()?; + let undefined = match p.get("undefinedAreas") { + None | Some(Value::Null) => "WrpA", + Some(value) => lookup_back(WAVE_UNDEFINED, value.as_str()?)?, + }; + let grid = grid.into_iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + Some(desc("null").with("ShrP", D::List(grid)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(end))) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f153ee83af..c9c2fcc4a9 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -526,6 +526,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})), ("filter.distort.pinch", json!({"amount": -30})), ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), ("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})), @@ -5882,3 +5883,169 @@ fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { assert_eq!(channel.unsupported.len(), 1, "{o} on a channel was mapped: {:?}", channel.actions[0].steps); } } + +/// The measured Shear list uses ordered horizontal/vertical doubles inside point objects. +fn shear_descriptor(points: &[[f64; 2]], undefined: &str) -> Descriptor { + let list = points.iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + desc("null").with("ShrP", D::List(list)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(points.len() as i32 - 1)) +} + +/// Both event identities, changed curves and missing-key defaults save every measured setting in order. +#[test] +fn shear_steps_roundtrip_curves_defaults_and_flags() { + let factory = shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "WrpA"); + for event in ["Shr ", "shear"] { + for d in [factory.clone(), shear_descriptor(&[[-32.0, 1.0], [16.0, 64.5], [0.0, 128.0]], "RptE")] { + let loaded = mapped_recording(event, d.clone()); + assert!(loaded.unsupported.is_empty()); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "filter.distort.shear"); + let bytes = export("S", &[action], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0].event, AtnEvent::Code(*b"Shr ")); + assert_eq!(saved.actions[0].items[0].descriptor, Some(d)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); + } + } + let loaded = mapped_recording("Shr ", desc("null")); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1, json!({"points":[[0,0],[1,0]],"undefinedAreas":"wrap"})); + assert_eq!(export_item("filter.distort.shear", &loaded.actions[0].steps[0].1, "S").unwrap().descriptor, Some(factory)); + let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); + step.enabled = false; + step.with_dialog = true; + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0], step); +} + +/// Clamp hostile coordinates, but retain finite fractional wire values that division by127 cannot invert. +#[test] +fn shear_clamps_points_and_preserves_fractional_wire_values() { + let raw = shear_descriptor(&[[-10000.0, -1000.0], [10000.0, 1000.0]], "WrpA"); + let loaded = mapped_recording("Shr ", raw); + assert!(loaded.unsupported.is_empty()); + let p = &loaded.actions[0].steps[0].1; + assert_eq!(p["points"], json!([[0, -1], [1, 63.0 / 64.0]])); + assert_eq!(export_item("filter.distort.shear", p, "S").unwrap().descriptor, Some(shear_descriptor(&[[-64.0, 1.0], [63.0, 128.0]], "WrpA"))); + + let d = shear_descriptor(&[[0.0, 1.0], [7.97, 4.975609756097561], [0.0, 128.0]], "RptE"); + let loaded = mapped_recording("shear", d.clone()); + assert!(loaded.unsupported.is_empty()); + let mut p = loaded.actions[0].steps[0].1.clone(); + assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); + assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); + p["points"][1][1] = json!(0.25); + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); +} + +/// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. +#[test] +fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { + let full = shear_descriptor(&[[0.0, 1.0], [8.0, 64.5], [0.0, 128.0]], "WrpA"); + let mut missing_end = full.clone(); + missing_end.items.pop(); // Missing ShrE means1, not the supplied list's last index2. + let mut invalid_start = full.clone(); + invalid_start.items[2].1 = D::Integer(1); + let mut invalid_end = full.clone(); + invalid_end.items[3].1 = D::Integer(100); + let mut wrong_type = full.clone(); + wrong_type.items[2].1 = D::Double(0.0); + for d in [ + missing_end, + invalid_start, + invalid_end, + wrong_type, + full.clone().with("ShrS", D::Integer(0)), + full.clone().with("extra", D::Boolean(true)), + desc("null").with("ShrP", D::List(vec![])), + desc("null").with("ShrP", D::List(vec![D::Integer(7)])), + desc("null").with("ShrP", D::List(vec![D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(0.0)))])), + desc("null").with("UndA", enm("UndA", "Trns")), + desc("null").with("UndA", enm("Other", "WrpA")), + shear_descriptor(&[[0.0, 128.0], [0.0, 1.0]], "WrpA"), + shear_descriptor(&[[0.0, 1.0], [8.0, 1.0], [0.0, 128.0]], "WrpA"), + ] { + let step = item("Shr ", "Shear", Some(d)); + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{step:?}"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], step); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let name = loaded.actions[0].name.clone(); + s.actions.list.extend(loaded.actions); + let report = s.execute("actions.play", json!({"action":name})).unwrap(); + assert_eq!(report["ran"], 0); + assert!(report.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } + for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(matches!(steps_distort::shear_from(&shear_descriptor(&[[n, 1.0], [0.0, 128.0]], "WrpA")), Mapped::Unsupported { .. })); + } +} + +/// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. +#[test] +fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { + for p in [json!({}), json!({"amount":25}), json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"})] { + let item = export_item("filter.distort.shear", &p, "S").unwrap(); + let Mapped::Steps(steps) = steps_distort::shear_from(item.descriptor.as_ref().unwrap()) else { panic!("export did not map back") }; + assert_eq!(crate::filters_ext::params_for("filter.distort.shear", &steps[0].1), crate::filters_ext::params_for("filter.distort.shear", &p)); + } + for p in [ + json!({"points":[]}), + json!({"points":[[0,0],[1,"bad"]]}), + json!({"points":[[1,0],[0,0]]}), + json!({"points":[[0,0],[1,1]]}), + json!({"amount":100}), + json!({"undefinedAreas":"transparent"}), + json!({"target":"mask"}), + json!({"layer":1}), + json!({"other":true}), + json!({"points":[[0,0],[1,0]],"_atnShearPoints":[[0,1],[8,128]]}), + ] { + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "{p} exported"); + } +} + +/// A constant measured curve shifts pixels right by two, with independent wrap/repeat border and Undo oracles. +#[test] +fn shear_action_moves_real_pixels_and_undoes_at_each_depth() { + for depth in [8, 16, 32] { + for edge in ["WrpA", "RptE"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.edit("Pattern", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + let data: Vec = (0..8).flat_map(|y| (0..32).flat_map(move |x| [x as f32 / 31.0, y as f32 / 7.0, (x % 3) as f32 / 2.0, 1.0])).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 32, 8), &data); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before); + let bytes = atn::write(&one_action(vec![item("Shr ", "Shear", Some(shear_descriptor(&[[8.0, 1.0], [8.0, 128.0]], edge)))])).unwrap(); + let loaded = s.execute("actions.load", json!({"data":b64(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let output = photocraft_compose::flatten(&s.active().unwrap().doc); + assert_ne!(output, pixels); + for y in 0..8 { + for x in 0..32 { + let source = if edge == "WrpA" { (x - 2i32).rem_euclid(32) } else { (x - 2i32).max(0) }; + for (got, want) in output.get(x, y).into_iter().zip(pixels.get(source, y)) { + assert!((got - want).abs() < 1e-5, "depth{depth} {edge} ({x},{y}):{got} != {want}"); + } + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} diff --git a/docs/atn.md b/docs/atn.md index 99f883fc98..e2dfb62d62 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -18,7 +18,7 @@ layers and documents, newer and older events and files, and a measured reference map yet, with everything needed to implement them without the reference application. -## Supported events (144) +## Supported events (145) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -127,6 +127,7 @@ the reference application. | (string ID only) | removeBackground | Remove Background | `layer.removeBackground` | | `RmvB` | removeBlackMatte | Remove Black Matte | `layer.matting.removeBlackMatte` | | `RmvW` | removeWhiteMatte | Remove White Matte | `layer.matting.removeWhiteMatte` | +| `Shr ` | shear | Shear | `filter.distort.shear` | | `Rset` | reset | Reset | `tools.defaultColors` | | `RvlA` | revealAll | Reveal All | `image.revealAll` | | `Rvrt` | revert | Revert | `file.revert` | @@ -1318,6 +1319,14 @@ All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping i - PhotoCraft command: `filter.distort.shear` - `shearPoints` is a list of curve points; keep their order and count. +Supported for complete curves: import clamps finite grid coordinates to the measured ranges below, +maps them to the native top-to-bottom position `(Vrtc - 1) / 127` and half-width offset `Hrzn / 64`, +and preserves fractional recorded coordinates exactly on save. Both edge modes are supported. +Only `ShrS` 0 and `ShrE` equal to the last point's index map; an omitted `ShrE` still defaults to 1. +Malformed settings, unknown keys, nonfinite values, and curves whose order or points the native +renderer would change remain complete kept steps. Export refuses unrepresentable native options +and mask targets. The native Shear renderer and its ordinary command behavior are unchanged. + | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| | `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`) from top to bottom; default two points (0, 1) and (0, 128). The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid edge clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. A script run accepts any value without clamping (e.g. `Hrzn` ±10000, fractions) and crashed on a first point with `Vrtc` < 1: clamp to the dialog ranges on import | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0821f154e..56d04cdc13 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (144 supported event IDs, the table in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (145 supported event IDs, the table in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -151,6 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | From 1a00bffee84e924480206ff0634a2c71d9e0581d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 17:04:30 -0800 Subject: [PATCH 15/42] Run public Shear regression tests in the integration harness --- crates/engine/src/cmd/atn_cmds/tests.rs | 166 +----------------- crates/engine/tests/it/atn_shear.rs | 213 ++++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 3 files changed, 219 insertions(+), 161 deletions(-) create mode 100644 crates/engine/tests/it/atn_shear.rs diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c9c2fcc4a9..e68775db90 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -5884,168 +5884,12 @@ fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { } } -/// The measured Shear list uses ordered horizontal/vertical doubles inside point objects. -fn shear_descriptor(points: &[[f64; 2]], undefined: &str) -> Descriptor { - let list = points.iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); - desc("null").with("ShrP", D::List(list)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(points.len() as i32 - 1)) -} - -/// Both event identities, changed curves and missing-key defaults save every measured setting in order. +/// Nonfinite recorded doubles remain unsupported before native parameters can be constructed. #[test] -fn shear_steps_roundtrip_curves_defaults_and_flags() { - let factory = shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "WrpA"); - for event in ["Shr ", "shear"] { - for d in [factory.clone(), shear_descriptor(&[[-32.0, 1.0], [16.0, 64.5], [0.0, 128.0]], "RptE")] { - let loaded = mapped_recording(event, d.clone()); - assert!(loaded.unsupported.is_empty()); - let action = &loaded.actions[0]; - assert_eq!(action.steps[0].0, "filter.distort.shear"); - let bytes = export("S", &[action], &[], false).unwrap().0; - let saved = atn::parse(&bytes).unwrap(); - assert_eq!(saved.actions[0].items[0].event, AtnEvent::Code(*b"Shr ")); - assert_eq!(saved.actions[0].items[0].descriptor, Some(d)); - assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); - } - } - let loaded = mapped_recording("Shr ", desc("null")); - assert!(loaded.unsupported.is_empty()); - assert_eq!(loaded.actions[0].steps[0].1, json!({"points":[[0,0],[1,0]],"undefinedAreas":"wrap"})); - assert_eq!(export_item("filter.distort.shear", &loaded.actions[0].steps[0].1, "S").unwrap().descriptor, Some(factory)); - let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); - step.enabled = false; - step.with_dialog = true; - let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); - let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; - let saved = atn::parse(&bytes).unwrap(); - assert_eq!(saved.actions[0].items[0], step); -} - -/// Clamp hostile coordinates, but retain finite fractional wire values that division by127 cannot invert. -#[test] -fn shear_clamps_points_and_preserves_fractional_wire_values() { - let raw = shear_descriptor(&[[-10000.0, -1000.0], [10000.0, 1000.0]], "WrpA"); - let loaded = mapped_recording("Shr ", raw); - assert!(loaded.unsupported.is_empty()); - let p = &loaded.actions[0].steps[0].1; - assert_eq!(p["points"], json!([[0, -1], [1, 63.0 / 64.0]])); - assert_eq!(export_item("filter.distort.shear", p, "S").unwrap().descriptor, Some(shear_descriptor(&[[-64.0, 1.0], [63.0, 128.0]], "WrpA"))); - - let d = shear_descriptor(&[[0.0, 1.0], [7.97, 4.975609756097561], [0.0, 128.0]], "RptE"); - let loaded = mapped_recording("shear", d.clone()); - assert!(loaded.unsupported.is_empty()); - let mut p = loaded.actions[0].steps[0].1.clone(); - assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); - assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); - p["points"][1][1] = json!(0.25); - assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); -} - -/// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. -#[test] -fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { - let full = shear_descriptor(&[[0.0, 1.0], [8.0, 64.5], [0.0, 128.0]], "WrpA"); - let mut missing_end = full.clone(); - missing_end.items.pop(); // Missing ShrE means1, not the supplied list's last index2. - let mut invalid_start = full.clone(); - invalid_start.items[2].1 = D::Integer(1); - let mut invalid_end = full.clone(); - invalid_end.items[3].1 = D::Integer(100); - let mut wrong_type = full.clone(); - wrong_type.items[2].1 = D::Double(0.0); - for d in [ - missing_end, - invalid_start, - invalid_end, - wrong_type, - full.clone().with("ShrS", D::Integer(0)), - full.clone().with("extra", D::Boolean(true)), - desc("null").with("ShrP", D::List(vec![])), - desc("null").with("ShrP", D::List(vec![D::Integer(7)])), - desc("null").with("ShrP", D::List(vec![D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(0.0)))])), - desc("null").with("UndA", enm("UndA", "Trns")), - desc("null").with("UndA", enm("Other", "WrpA")), - shear_descriptor(&[[0.0, 128.0], [0.0, 1.0]], "WrpA"), - shear_descriptor(&[[0.0, 1.0], [8.0, 1.0], [0.0, 128.0]], "WrpA"), - ] { - let step = item("Shr ", "Shear", Some(d)); - let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); - assert_eq!(loaded.unsupported.len(), 1, "{step:?}"); - let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; - assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], step); - let mut s = Session::new(); - s.execute("file.new", json!({"width":8,"height":8})).unwrap(); - let before = s.active().unwrap().doc.clone(); - let past = s.active().unwrap().history.past_len(); - let name = loaded.actions[0].name.clone(); - s.actions.list.extend(loaded.actions); - let report = s.execute("actions.play", json!({"action":name})).unwrap(); - assert_eq!(report["ran"], 0); - assert!(report.get("failed").is_some()); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().history.past_len(), past); - } +fn shear_nonfinite_coordinates_stay_kept() { for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert!(matches!(steps_distort::shear_from(&shear_descriptor(&[[n, 1.0], [0.0, 128.0]], "WrpA")), Mapped::Unsupported { .. })); - } -} - -/// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. -#[test] -fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { - for p in [json!({}), json!({"amount":25}), json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"})] { - let item = export_item("filter.distort.shear", &p, "S").unwrap(); - let Mapped::Steps(steps) = steps_distort::shear_from(item.descriptor.as_ref().unwrap()) else { panic!("export did not map back") }; - assert_eq!(crate::filters_ext::params_for("filter.distort.shear", &steps[0].1), crate::filters_ext::params_for("filter.distort.shear", &p)); - } - for p in [ - json!({"points":[]}), - json!({"points":[[0,0],[1,"bad"]]}), - json!({"points":[[1,0],[0,0]]}), - json!({"points":[[0,0],[1,1]]}), - json!({"amount":100}), - json!({"undefinedAreas":"transparent"}), - json!({"target":"mask"}), - json!({"layer":1}), - json!({"other":true}), - json!({"points":[[0,0],[1,0]],"_atnShearPoints":[[0,1],[8,128]]}), - ] { - assert!(export_item("filter.distort.shear", &p, "S").is_none(), "{p} exported"); - } -} - -/// A constant measured curve shifts pixels right by two, with independent wrap/repeat border and Undo oracles. -#[test] -fn shear_action_moves_real_pixels_and_undoes_at_each_depth() { - for depth in [8, 16, 32] { - for edge in ["WrpA", "RptE"] { - let mut s = Session::new(); - s.execute("file.new", json!({"width":32,"height":8,"depth":depth,"background":"transparent"})).unwrap(); - s.edit("Pattern", |doc, _| { - let surface = doc.layers[0].surface_mut().unwrap(); - let data: Vec = (0..8).flat_map(|y| (0..32).flat_map(move |x| [x as f32 / 31.0, y as f32 / 7.0, (x % 3) as f32 / 2.0, 1.0])).collect(); - surface.write_region(photocraft_geom::Rect::new(0, 0, 32, 8), &data); - Ok(()) - }) - .unwrap(); - let before = s.active().unwrap().doc.clone(); - let pixels = photocraft_compose::flatten(&before); - let bytes = atn::write(&one_action(vec![item("Shr ", "Shear", Some(shear_descriptor(&[[8.0, 1.0], [8.0, 128.0]], edge)))])).unwrap(); - let loaded = s.execute("actions.load", json!({"data":b64(&bytes)})).unwrap(); - assert_eq!(loaded["unsupported"], json!([])); - let played = s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap(); - assert_eq!(played["ran"], 1, "{played}"); - let output = photocraft_compose::flatten(&s.active().unwrap().doc); - assert_ne!(output, pixels); - for y in 0..8 { - for x in 0..32 { - let source = if edge == "WrpA" { (x - 2i32).rem_euclid(32) } else { (x - 2i32).max(0) }; - for (got, want) in output.get(x, y).into_iter().zip(pixels.get(source, y)) { - assert!((got - want).abs() < 1e-5, "depth{depth} {edge} ({x},{y}):{got} != {want}"); - } - } - } - assert!(s.undo()); - assert_eq!(s.active().unwrap().doc, before); - } + let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y))); + let d = desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])); + assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. })); } } diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs new file mode 100644 index 0000000000..c82e0dace6 --- /dev/null +++ b/crates/engine/tests/it/atn_shear.rs @@ -0,0 +1,213 @@ +//! Public load/save/playback witnesses for the measured Shear action mapping. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{Imported, export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn desc(class: &str) -> Descriptor { + Descriptor::new(class) +} +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} +/// Four-byte recorded IDs retain their code form; longer IDs exercise string-event lookup. +fn item(event: &str, label: &str, descriptor: Option) -> AtnItem { + let event = match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }; + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor, ..Default::default() } +} +fn one_action(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("Saves"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + ..Default::default() + } +} +fn b64(bytes: &[u8]) -> String { + photocraft_engine::paint::tile::b64_encode(bytes) +} +fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { + import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() +} +/// Save one real native action and parse its public output, rather than calling a private converter. +fn export_item(id: &str, params: &Value, set: &str) -> Option { + let action = Action::new("A", vec![(id.into(), params.clone())]); + let bytes = export(set, &[&action], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.first()?.items.first().cloned() +} + +/// The measured Shear list uses ordered horizontal/vertical doubles inside point objects. +fn shear_descriptor(points: &[[f64; 2]], undefined: &str) -> Descriptor { + let list = points.iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + desc("null").with("ShrP", D::List(list)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(points.len() as i32 - 1)) +} + +/// Both event identities, changed curves and missing-key defaults save every measured setting in order. +#[test] +fn shear_steps_roundtrip_curves_defaults_and_flags() { + let factory = shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "WrpA"); + for event in ["Shr ", "shear"] { + for d in [factory.clone(), shear_descriptor(&[[-32.0, 1.0], [16.0, 64.5], [0.0, 128.0]], "RptE")] { + let loaded = mapped_recording(event, d.clone()); + assert!(loaded.unsupported.is_empty()); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "filter.distort.shear"); + let bytes = export("S", &[action], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0].event, AtnEvent::Code(*b"Shr ")); + assert_eq!(saved.actions[0].items[0].descriptor, Some(d)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); + } + } + let loaded = mapped_recording("Shr ", desc("null")); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1, json!({"points":[[0,0],[1,0]],"undefinedAreas":"wrap"})); + assert_eq!(export_item("filter.distort.shear", &loaded.actions[0].steps[0].1, "S").unwrap().descriptor, Some(factory)); + let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); + step.enabled = false; + step.with_dialog = true; + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0], step); +} + +/// Clamp hostile coordinates, but retain finite fractional wire values that division by127 cannot invert. +#[test] +fn shear_clamps_points_and_preserves_fractional_wire_values() { + let raw = shear_descriptor(&[[-10000.0, -1000.0], [10000.0, 1000.0]], "WrpA"); + let loaded = mapped_recording("Shr ", raw); + assert!(loaded.unsupported.is_empty()); + let p = &loaded.actions[0].steps[0].1; + assert_eq!(p["points"], json!([[0, -1], [1, 63.0 / 64.0]])); + assert_eq!(export_item("filter.distort.shear", p, "S").unwrap().descriptor, Some(shear_descriptor(&[[-64.0, 1.0], [63.0, 128.0]], "WrpA"))); + + let d = shear_descriptor(&[[0.0, 1.0], [7.97, 4.975609756097561], [0.0, 128.0]], "RptE"); + let loaded = mapped_recording("shear", d.clone()); + assert!(loaded.unsupported.is_empty()); + let mut p = loaded.actions[0].steps[0].1.clone(); + assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); + assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); + p["points"][1][1] = json!(0.25); + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); +} + +/// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. +#[test] +fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { + let full = shear_descriptor(&[[0.0, 1.0], [8.0, 64.5], [0.0, 128.0]], "WrpA"); + let mut missing_end = full.clone(); + missing_end.items.pop(); // Missing ShrE means1, not the supplied list's last index2. + let mut invalid_start = full.clone(); + invalid_start.items[2].1 = D::Integer(1); + let mut invalid_end = full.clone(); + invalid_end.items[3].1 = D::Integer(100); + let mut wrong_type = full.clone(); + wrong_type.items[2].1 = D::Double(0.0); + for d in [ + missing_end, + invalid_start, + invalid_end, + wrong_type, + full.clone().with("ShrS", D::Integer(0)), + full.clone().with("extra", D::Boolean(true)), + desc("null").with("ShrP", D::List(vec![])), + desc("null").with("ShrP", D::List(vec![D::Integer(7)])), + desc("null").with("ShrP", D::List(vec![D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(0.0)))])), + desc("null").with("UndA", enm("UndA", "Trns")), + desc("null").with("UndA", enm("Other", "WrpA")), + shear_descriptor(&[[0.0, 128.0], [0.0, 1.0]], "WrpA"), + shear_descriptor(&[[0.0, 1.0], [8.0, 1.0], [0.0, 128.0]], "WrpA"), + ] { + let step = item("Shr ", "Shear", Some(d)); + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{step:?}"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], step); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let name = loaded.actions[0].name.clone(); + s.actions.list.extend(loaded.actions); + let report = s.execute("actions.play", json!({"action":name})).unwrap(); + assert_eq!(report["ran"], 0); + assert!(report.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. +#[test] +fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { + for p in [json!({}), json!({"amount":25}), json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"})] { + let item = export_item("filter.distort.shear", &p, "S").unwrap(); + let loaded = mapped_recording("Shr ", item.descriptor.unwrap()); + assert!(loaded.unsupported.is_empty()); + let steps = &loaded.actions[0].steps; + assert_eq!( + photocraft_engine::filters_ext::params_for("filter.distort.shear", &steps[0].1), + photocraft_engine::filters_ext::params_for("filter.distort.shear", &p) + ); + } + for p in [ + json!({"points":[]}), + json!({"points":[[0,0],[1,"bad"]]}), + json!({"points":[[1,0],[0,0]]}), + json!({"points":[[0,0],[1,1]]}), + json!({"amount":100}), + json!({"undefinedAreas":"transparent"}), + json!({"target":"mask"}), + json!({"layer":1}), + json!({"other":true}), + json!({"points":[[0,0],[1,0]],"_atnShearPoints":[[0,1],[8,128]]}), + ] { + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "{p} exported"); + } +} + +/// A constant measured curve shifts pixels right by two, with independent wrap/repeat border and Undo oracles. +#[test] +fn shear_action_moves_real_pixels_and_undoes_at_each_depth() { + for depth in [8, 16, 32] { + for edge in ["WrpA", "RptE"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.edit("Pattern", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + let data: Vec = (0..8).flat_map(|y| (0..32).flat_map(move |x| [x as f32 / 31.0, y as f32 / 7.0, (x % 3) as f32 / 2.0, 1.0])).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 32, 8), &data); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before); + let bytes = atn::write(&one_action(vec![item("Shr ", "Shear", Some(shear_descriptor(&[[8.0, 1.0], [8.0, 128.0]], edge)))])).unwrap(); + let loaded = s.execute("actions.load", json!({"data":b64(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let output = photocraft_compose::flatten(&s.active().unwrap().doc); + assert_ne!(output, pixels); + for y in 0..8 { + for x in 0..32 { + let source = if edge == "WrpA" { (x - 2i32).rem_euclid(32) } else { (x - 2i32).max(0) }; + for (got, want) in output.get(x, y).into_iter().zip(pixels.get(source, y)) { + assert!((got - want).abs() < 1e-5, "depth{depth} {edge} ({x},{y}):{got} != {want}"); + } + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 467b04720d..a7fd68f125 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -6,6 +6,7 @@ mod atn_crystallize_cell_size; mod atn_reference_forms; mod atn_ripple_amount; mod atn_selective_color; +mod atn_shear; mod atn_stroke_width; mod atn_v12; mod background_and_mask_depth; From 8c576e5f942876cd1e2f8b9b047f8923c5cd156d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 17:06:12 -0800 Subject: [PATCH 16/42] Use the recorded show-dialog mode in Shear flag fixture --- crates/engine/tests/it/atn_shear.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs index c82e0dace6..dfa5d3ffd8 100644 --- a/crates/engine/tests/it/atn_shear.rs +++ b/crates/engine/tests/it/atn_shear.rs @@ -74,6 +74,7 @@ fn shear_steps_roundtrip_curves_defaults_and_flags() { let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); step.enabled = false; step.with_dialog = true; + step.dialog_options = 2; // The wire mode for showing the enabled dialog toggle. let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; let saved = atn::parse(&bytes).unwrap(); From bbf0caa8c1d7010cc4efaf1aa32e8bc3c7211aff Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:50:06 +0100 Subject: [PATCH 17/42] Crop: cover more Ctrl-held Straighten cases (#10870) Review follow-up. The Ctrl-held tests now also start from a frame drawn off the canvas's centre (it keeps its size and position, and a second line on the turned frame sets its own angle), a 1:1 preset frame, Delete Cropped Pixels off, Ctrl let go before the mouse button, a click or tiny line, lines either side of 45 degrees, and Undo/Redo before and after the commit. The button test also fits a 1:1 frame and checks it keeps its ratio. Comments and test helpers documented. --- crates/ui-egui/src/tools/crop_straighten.rs | 17 +- .../ui-egui/tests/it/crop_ctrl_straighten.rs | 242 +++++++++++++++--- 2 files changed, 222 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_straighten.rs b/crates/ui-egui/src/tools/crop_straighten.rs index d26b111104..939953fd02 100644 --- a/crates/ui-egui/src/tools/crop_straighten.rs +++ b/crates/ui-egui/src/tools/crop_straighten.rs @@ -11,10 +11,10 @@ //! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle. //! The button's line also fits the frame to the image ([`fitted`]); a ⌘-held line keeps the //! frame's size and position, so ↵ fills the corners the turn uncovers, as Photoshop does -//! (measured: an 800 × 500 frame stays 800 × 500, #10870). In Classic Mode the frame then shows turned over -//! the image, in the default mode the image turns behind the upright box (`crop_mode`). It is only a pending frame edit: -//! ↵ commits it and Esc cancels it as any crop. A line shorter than [`MIN_LINE_PX`] screen points -//! does nothing. +//! (measured: an 800 × 500 frame stays 800 × 500, #10870). In Classic Mode the frame then shows +//! turned over the image, in the default mode the image turns behind the upright box +//! (`crop_mode`). It is only a pending frame edit: ↵ commits it and Esc cancels it as any crop. A +//! line shorter than [`MIN_LINE_PX`] screen points does nothing. use egui::{Modifiers, Pos2}; @@ -89,8 +89,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => { let Some([a, _]) = app.crop.straighten.line.take() else { return false }; - // One line per click of the button. Only the button's line fits the frame: a line - // drawn with ⌘ held keeps the frame's size. + // One line per click of the button. Only the button's line fits the frame (also with + // ⌘ held while it is armed); a ⌘ line keeps the frame's size, even when ⌘ is let go + // before the mouse button. let fit = std::mem::take(&mut app.crop.straighten.armed); let zoom = f64::from(app.point_zoom()); apply(app, a, [x, y], zoom, fit); @@ -139,10 +140,10 @@ fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64, fit: bool let min_len = if zoom.is_finite() && zoom > 0.0 { MIN_LINE_PX / zoom } else { MIN_LINE_PX }; let Some(deg) = frame_angle(a, b, min_len) else { return }; let Some(size) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; - // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame - // takes the canvas's. let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())); let r = if fit { + // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame + // takes the canvas's. let (w, h) = frame.map_or(size, |r| (r[2] - r[0], r[3] - r[1])); let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; r diff --git a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs index 0e888e1307..3aa235e545 100644 --- a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -11,33 +11,46 @@ use photocraft_ui_egui::PhotocraftApp; use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_ui::{angle, corners as turned_corners, pending}; use serde_json::{Value, json}; +/// The background colour, #1050e0: what the turn uncovers is filled with it. const BLUE: [f32; 3] = [16.0 / 255.0, 80.0 / 255.0, 224.0 / 255.0]; -fn app(classic: bool) -> (PhotocraftApp, egui::Context) { +/// An 800 × 500 white document with a red 40 px square centred on (300, 200), the Crop tool +/// picked with options-bar ratio `ratio` ("" for none) in Classic Mode or the default mode, and +/// its default frame. +fn app(classic: bool, ratio: &str) -> (PhotocraftApp, egui::Context) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let ctx = egui::Context::default(); app.run("file.new", json!({"width": 800, "height": 500, "background": "white"})).expect("new"); + app.run("select.rect", json!({"x": 280, "y": 180, "width": 40, "height": 40})).expect("select"); + app.run("edit.fill", json!({"color": "#ff0000"})).expect("fill"); + app.run("select.deselect", json!({})).expect("deselect"); app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); app.sync_views(); app.ui.extras.snap = false; app.ui.view.show.smart_guides = false; app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.tool_options.crop_ratio = ratio.into(); control(&mut app, &ctx, "ui.set", json!({"tool": "crop"})); photocraft_ui_egui::crop_ui::ensure_frame(&mut app); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "the default frame"); (app, ctx) } +/// Runs one control-channel request, which must succeed. fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { let (req, _rx) = ControlRequest::new(method, params.clone()); assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); } -/// A line rising 10° to the right across the canvas, `command` held or not. -fn line(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool) { - let (a, b) = ([100.0, 300.0], [700.0, 300.0 - 600.0 * 10.0f64.to_radians().tan()]); +/// One `ui.pointer` call: `events` with ⌘ held or not. +fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool, events: Value) { + control(app, ctx, "ui.pointer", json!({"command": command, "events": events})); +} + +/// A drag from `a` to `b` through their midpoint, `command` held or not. +fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool, a: [f64; 2], b: [f64; 2]) { let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; let events = json!([ {"kind": "down", "x": a[0], "y": a[1]}, @@ -45,46 +58,217 @@ fn line(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool) { {"kind": "move", "x": b[0], "y": b[1]}, {"kind": "up", "x": b[0], "y": b[1]}, ]); - control(app, ctx, "ui.pointer", json!({"command": command, "events": events})); + pointer(app, ctx, command, events); } +/// The issue's line's ends: rising 10° to the right across the canvas. +fn ends() -> ([f64; 2], [f64; 2]) { + ([100.0, 300.0], [700.0, 300.0 - 600.0 * 10.0f64.to_radians().tan()]) +} + +/// The issue's line, `command` held or not. +fn line(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool) { + let (a, b) = ends(); + drag(app, ctx, command, a, b); +} + +/// ↵: commits the crop and returns the document's new size. fn committed(app: &mut PhotocraftApp) -> (u32, u32) { photocraft_ui_egui::canvas::commit_crop(app); let size = app.session.active().expect("document").doc.size; (size.width, size.height) } +/// The flattened document's pixel at (`x`, `y`). +fn pixel(app: &PhotocraftApp, x: usize, y: usize) -> [f32; 4] { + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + flat.px[y * doc.size.width as usize + x] +} + +fn is_blue(p: [f32; 4]) -> bool { + BLUE.iter().zip(p).all(|(w, g)| (w - g).abs() < 0.02) +} + +fn is_white(p: [f32; 4]) -> bool { + p[..3].iter().all(|&v| v > 0.98) +} + +fn is_red(p: [f32; 4]) -> bool { + p[0] > 0.98 && p[1] < 0.02 && p[2] < 0.02 +} + +/// The four corner pixels of the document, in `crop_ui::corners`' order: top-left, top-right, +/// bottom-right, bottom-left. +fn corners(app: &PhotocraftApp) -> [[f32; 4]; 4] { + let size = app.session.active().expect("document").doc.size; + let (r, b) = (size.width as usize - 1, size.height as usize - 1); + [pixel(app, 0, 0), pixel(app, r, 0), pixel(app, r, b), pixel(app, 0, b)] +} + +/// The issue's row: the frame turns and keeps its size and position, and ↵ fills what the turn +/// uncovers. Also with a 1:1 ratio preset's frame and with Delete Cropped Pixels off. #[test] fn a_command_held_straighten_line_keeps_the_frame_size() { for classic in [false, true] { - let (mut app, ctx) = app(classic); - line(&mut app, &ctx, true); - // The frame turns (counter-clockwise: the line rises to the right) and stays put. - assert!((photocraft_ui_egui::crop_ui::angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}"); - assert!(photocraft_ui_egui::crop_ui::pending(&app), "classic {classic}: a pending edit"); - assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); - // The corners the turn uncovered take the background colour; the middle is the image. - let doc = &app.session.active().expect("document").doc; - let flat = photocraft_compose::flatten(doc); - let at = |x: usize, y: usize| flat.px[y * 800 + x]; - for (x, y) in [(0, 0), (799, 0), (0, 499), (799, 499)] { - let p = at(x, y); - assert!(BLUE.iter().zip(p).all(|(w, g)| (w - g).abs() < 0.02), "classic {classic}: corner ({x}, {y}) {p:?}"); + for (ratio, frame) in [("", [0.0, 0.0, 800.0, 500.0]), ("1:1", [150.0, 0.0, 650.0, 500.0])] { + for delete in [true, false] { + let what = format!("classic {classic}, ratio {ratio:?}, delete {delete}"); + let (mut app, ctx) = app(classic, ratio); + app.ui.tool_options.crop_delete = delete; + assert_eq!(app.ui.crop_rect, Some(frame), "{what}: the default frame"); + line(&mut app, &ctx, true); + // The frame turns (counter-clockwise: the line rises to the right) and stays put. + assert!((angle(&app) + 10.0).abs() < 1e-6, "{what}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "{what}"); + assert!(pending(&app), "{what}: a pending edit"); + assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "{what}: the line ended"); + // Where the turned frame's corners land on the canvas: the whole-canvas frame's + // all leave it; the 500 × 500 one's top-right and bottom-left leave it at the top + // and bottom, the other two stay inside the 800 px width. + let outside = turned_corners(frame, angle(&app)).map(|c| !(0.0..=800.0).contains(&c[0]) || !(0.0..=500.0).contains(&c[1])); + let size = (frame[2] - frame[0], frame[3] - frame[1]); + assert_eq!(committed(&mut app), (size.0 as u32, size.1 as u32), "{what}"); + // The corners the turn uncovered take the background colour, the others and the + // middle are the image. + for (p, out) in corners(&app).into_iter().zip(outside) { + assert!(if out { is_blue(p) } else { is_white(p) }, "{what}: a corner {} the canvas is {p:?}", if out { "off" } else { "on" }); + } + assert_eq!(outside, if ratio.is_empty() { [true; 4] } else { [false, true, false, true] }, "{what}"); + let mid = pixel(&app, (size.0 / 2.0) as usize, (size.1 / 2.0) as usize); + assert!(is_white(mid), "{what}: the white image in the middle, not {mid:?}"); + } } - assert!(at(400, 250)[..3].iter().all(|&v| v > 0.98), "classic {classic}: the white image in the middle"); } } +/// A frame drawn off the canvas's centre keeps its size and position through ⌘ lines, the +/// second one turning the already turned frame to its own angle. #[test] -fn the_straighten_button_still_fits_the_frame() { +fn a_command_held_line_keeps_a_drawn_frame_where_it_is() { + let frame = [100.0, 50.0, 500.0, 350.0]; for classic in [false, true] { - let (mut app, ctx) = app(classic); - photocraft_ui_egui::crop_straighten::toggle(&mut app); - line(&mut app, &ctx, false); - assert!((photocraft_ui_egui::crop_ui::angle(&app) + 10.0).abs() < 1e-6, "classic {classic}"); - assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "classic {classic}: one line per click"); - let (w, h) = committed(&mut app); - assert!(w.abs_diff(633) <= 1 && h.abs_diff(396) <= 1, "classic {classic}: {w} × {h}"); + let (mut app, ctx) = app(classic, ""); + // Auto Center Preview stays on (the default): in the default mode it pans the view to the + // drawn box, which must not move the frame on the image. + assert!(app.ui.tool_options.crop_shield.auto_center_preview); + drag(&mut app, &ctx, false, [frame[0], frame[1]], [frame[2], frame[3]]); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: the drawn frame"); + line(&mut app, &ctx, true); + assert!((angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: after the first line"); + // A line falling 4° to the right sets the frame's angle to 4°, not 10° - 4°. + drag(&mut app, &ctx, true, [150.0, 100.0], [450.0, 100.0 + 300.0 * 4.0f64.to_radians().tan()]); + assert!((angle(&app) - 4.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: after the second line"); + assert_eq!(committed(&mut app), (400, 300), "classic {classic}"); + // The crop is centred on the red square, where the frame was; turned 4° the frame stays + // inside the image, so nothing is filled. + assert!(is_red(pixel(&app, 200, 150)), "classic {classic}: the square in the middle, not {:?}", pixel(&app, 200, 150)); + for p in corners(&app) { + assert!(is_white(p), "classic {classic}: a corner is {p:?}"); + } + } +} + +/// ⌘ let go before the mouse button: the line started as a ⌘ line, so it still keeps the frame. +#[test] +fn releasing_command_before_the_end_of_the_line_still_keeps_the_frame() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + let (a, b) = ends(); + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + pointer(&mut app, &ctx, true, json!([{"kind": "down", "x": a[0], "y": a[1]}, {"kind": "move", "x": mid[0], "y": mid[1]}])); + pointer(&mut app, &ctx, false, json!([{"kind": "move", "x": b[0], "y": b[1]}, {"kind": "up", "x": b[0], "y": b[1]}])); + assert!((angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}"); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + } +} + +/// Either side of 45°: a line rising 44° to the right is levelled (the frame turns -44°), one +/// rising 46° is made plumb (it leans 44° the other way, so +44°). The frame keeps its size both +/// ways, however far it turns. +#[test] +fn a_steep_command_held_line_keeps_the_frame_level_or_plumb() { + for classic in [false, true] { + for (rise, want) in [(44.0f64, -44.0), (46.0, 44.0)] { + let what = format!("classic {classic}, rising {rise}°"); + let (mut app, ctx) = app(classic, ""); + let (s, c) = rise.to_radians().sin_cos(); + drag(&mut app, &ctx, true, [400.0 - 200.0 * c, 250.0 + 200.0 * s], [400.0 + 200.0 * c, 250.0 - 200.0 * s]); + assert!((angle(&app) - want).abs() < 1e-6, "{what}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "{what}"); + assert_eq!(committed(&mut app), (800, 500), "{what}"); + for p in corners(&app) { + assert!(is_blue(p), "{what}: a corner is {p:?}"); + } + } + } +} + +/// A ⌘-click, or a ⌘ line too short to measure, changes nothing: the default frame stays the +/// default, so ↵ crops nothing. +#[test] +fn a_command_held_click_or_tiny_line_changes_nothing() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + drag(&mut app, &ctx, true, [400.0, 250.0], [400.0, 250.0]); + drag(&mut app, &ctx, true, [400.0, 250.0], [400.001, 249.999]); + assert_eq!((app.ui.crop_rect, angle(&app)), (Some([0.0, 0.0, 800.0, 500.0]), 0.0), "classic {classic}"); + assert!(!pending(&app), "classic {classic}: still the default frame"); + let steps = app.session.active().expect("document").history.past_len(); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + assert_eq!(app.session.active().expect("document").history.past_len(), steps, "classic {classic}: no crop"); + } +} + +/// Undo steps the box back before ↵ (the frame upright, the default again) and Redo turns it +/// again; after ↵ the crop is one document step that Undo reverts. +#[test] +fn a_command_held_line_undoes_as_one_box_step_and_commits_as_one_document_step() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + line(&mut app, &ctx, true); + app.run("edit.undo", json!({})).expect("undo"); + assert_eq!((app.ui.crop_rect, angle(&app)), (Some([0.0, 0.0, 800.0, 500.0]), 0.0), "classic {classic}: undone"); + assert!(!pending(&app), "classic {classic}: the default frame again"); + app.run("edit.redo", json!({})).expect("redo"); + assert!((angle(&app) + 10.0).abs() < 1e-6 && pending(&app), "classic {classic}: redone"); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}: redone"); + let steps = app.session.active().expect("document").history.past_len(); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + assert_eq!(app.session.active().expect("document").history.past_len(), steps + 1, "classic {classic}"); + assert!(is_blue(pixel(&app, 0, 0)), "classic {classic}: filled"); + app.run("edit.undo", json!({})).expect("undo the crop"); + assert!(is_white(pixel(&app, 0, 0)) && is_red(pixel(&app, 300, 200)), "classic {classic}: the image as it was"); + } +} + +/// The button's line fits the frame inside the turned image, keeping the frame's proportions +/// (the canvas's, or a 1:1 preset's), also with ⌘ held while the button is armed. +#[test] +fn the_straighten_button_still_fits_the_frame() { + let (s, c) = 10.0f64.to_radians().sin_cos(); + // The 1:1 frame's side: its turned bounds fit the canvas's height. + let square = 500.0 / (s + c); + for classic in [false, true] { + for (ratio, command, want) in [("", false, (633.0, 396.0)), ("", true, (633.0, 396.0)), ("1:1", false, (square, square))] { + let what = format!("classic {classic}, ratio {ratio:?}, command {command}"); + let (mut app, ctx) = app(classic, ratio); + photocraft_ui_egui::crop_straighten::toggle(&mut app); + line(&mut app, &ctx, command); + assert!((angle(&app) + 10.0).abs() < 1e-6, "{what}"); + assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "{what}: one line per click"); + let (w, h) = committed(&mut app); + assert!((f64::from(w) - want.0).abs() <= 1.0 && (f64::from(h) - want.1).abs() <= 1.0, "{what}: {w} × {h}"); + if ratio == "1:1" { + assert_eq!(w, h, "{what}: keeps the frame's 1:1"); + } + // Fitted inside the turned image: nothing to fill. + for p in corners(&app) { + assert!(!is_blue(p), "{what}: a corner is {p:?}"); + } + } } } From 8e17f90c06107d3335c9af2a1f1912ede93f1e3c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 18:26:17 -0800 Subject: [PATCH 18/42] Bound Shear action curves and preserve native precision --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 43 ++++++++++++++--- crates/engine/tests/it/atn_shear.rs | 46 ++++++++++++++++++- docs/atn.md | 7 ++- docs/file-format-parity.md | 2 +- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index b34702a572..703b2327dd 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -220,9 +220,13 @@ pub(super) fn wave_to(_: &str, p: &Value) -> Option { Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed))) } -/// A curve must retain its order and points when the native renderer sorts and deduplicates it. +// Adapter safety budget, not a Photoshop format maximum: at most 64 KiB per f64 point +// vector and 32 KiB in the native renderer, whose per-row curve search is linear. +const MAX_SHEAR_POINTS: usize = 4096; + +/// A curve must fit the playback budget and retain its order through native deduplication. fn shear_curve(points: &[[f64; 2]]) -> bool { - points.len() >= 2 + (2..=MAX_SHEAR_POINTS).contains(&points.len()) && points.iter().all(|p| p[0].is_finite() && (0.0..=1.0).contains(&p[0]) && (-1.0..=63.0 / 64.0).contains(&p[1])) && points.windows(2).all(|p| p[1][0] as f32 - p[0][0] as f32 >= 1e-6) } @@ -235,6 +239,9 @@ fn shear_normalized(points: &[[f64; 2]]) -> Vec<[f64; 2]> { /// Read every measured point; an invalid entry must not disappear in the native list parser. fn shear_points(value: &D) -> Option> { let D::List(values) = value else { return None }; + if !(2..=MAX_SHEAR_POINTS).contains(&values.len()) { + return None; + } values .iter() .map(|v| { @@ -248,6 +255,21 @@ fn shear_points(value: &D) -> Option> { .collect() } +/// Bound native/source JSON lists before conversion, without cloning the input array. +fn shear_json_points(value: &Value) -> Option> { + let points = value.as_array()?; + if !(2..=MAX_SHEAR_POINTS).contains(&points.len()) { + return None; + } + points + .iter() + .map(|point| { + let [x, y] = point.as_array()?.as_slice() else { return None }; + Some([x.as_f64()?, y.as_f64()?]) + }) + .collect() +} + /// Import only the complete measured curve range, with dialog clamps and exact fractional save data. pub(super) fn shear_from(d: &Descriptor) -> Mapped { let mapped = || { @@ -290,16 +312,25 @@ pub(super) fn shear_to(_: &str, p: &Value) -> Option { let amount = (amount as f32).clamp(-100.0, 100.0) / 100.0; vec![[0.0, 0.0], [0.5, f64::from(amount)], [1.0, 0.0]] } - Some(value) => serde_json::from_value(value.clone()).ok()?, + Some(value) => shear_json_points(value)?, }; if !shear_curve(&points) { return None; } - let grid: Vec<[f64; 2]> = match p.get("_atnShearPoints") { + let stored = p.get("_atnShearPoints"); + let grid: Vec<[f64; 2]> = match stored { None => points.iter().map(|p| [p[1] * 64.0, p[0] * 127.0 + 1.0]).collect(), - Some(value) => serde_json::from_value(value.clone()).ok()?, + Some(value) => shear_json_points(value)?, }; - if grid.iter().any(|p| !(-64.0..=63.0).contains(&p[0]) || !(1.0..=128.0).contains(&p[1])) || shear_normalized(&grid) != points { + // Derived grid arithmetic may change an f64 wire bit without changing the f32 curve + // the native command consumes. Stored source coordinates still require exact agreement: + // even an edit below f32 precision must not revive stale recorded metadata. + let matches = grid.len() == points.len() + && grid.iter().zip(&points).all(|(grid, point)| { + let normalized = [(grid[1] - 1.0) / 127.0, grid[0] / 64.0]; + if stored.is_some() { normalized == *point } else { normalized.map(|v| v as f32) == point.map(|v| v as f32) } + }); + if grid.iter().any(|p| !(-64.0..=63.0).contains(&p[0]) || !(1.0..=128.0).contains(&p[1])) || !matches { return None; } let end = i32::try_from(grid.len().checked_sub(1)?).ok()?; diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs index dfa5d3ffd8..fb6e605d4d 100644 --- a/crates/engine/tests/it/atn_shear.rs +++ b/crates/engine/tests/it/atn_shear.rs @@ -97,10 +97,49 @@ fn shear_clamps_points_and_preserves_fractional_wire_values() { let mut p = loaded.actions[0].steps[0].1.clone(); assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); + let mut edited = p.clone(); + let position = p["points"][1][0].as_f64().unwrap(); + edited["points"][1][0] = json!(position + 1e-12); + assert_ne!(edited["points"][1][0], p["points"][1][0]); + assert_eq!(edited["points"][1][0].as_f64().unwrap() as f32, position as f32); + assert!(export_item("filter.distort.shear", &edited, "S").is_none(), "stored wire metadata rejects even an edit below native precision"); p["points"][1][1] = json!(0.25); assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); } +/// The adapter's engineering budget admits its boundary and preserves larger recordings intact. +#[test] +fn shear_point_budget_preserves_recordings_and_refuses_oversized_native_exports() { + const LIMIT: usize = 4096; + for count in [LIMIT, LIMIT + 1] { + let grid: Vec<_> = (0..count).map(|i| [0.0, 1.0 + 127.0 * i as f64 / (count - 1) as f64]).collect(); + let label = if count == LIMIT { "Shear" } else { "Budget curve" }; + let mut source = item("Shr ", label, Some(shear_descriptor(&grid, "WrpA"))); + source.enabled = false; + let loaded = import(&atn::write(&one_action(vec![source.clone()])).unwrap()).unwrap(); + let action = &loaded.actions[0]; + if count == LIMIT { + assert!(loaded.unsupported.is_empty()); + assert_eq!(action.steps[0].0, "filter.distort.shear"); + assert_eq!(action.steps[0].1["points"].as_array().unwrap().len(), LIMIT); + } else { + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(action.steps[0].0, "actions.photoshopStep"); + } + let saved = export("S", &[action], &[], false).unwrap().0; + assert!(atn::parse(&saved).unwrap().actions[0].items[0] == source, "{count}-point item changed on save"); + + let native = json!({"points":grid.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect::>()}); + let before = native.clone(); + assert_eq!(export_item("filter.distort.shear", &native, "S").is_some(), count == LIMIT); + assert_eq!(native, before, "export never edits the caller's curve"); + if count > LIMIT { + let metadata = json!({"points":[[0,0],[1,0]],"_atnShearPoints":grid}); + assert!(export_item("filter.distort.shear", &metadata, "S").is_none()); + } + } +} + /// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. #[test] fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { @@ -150,7 +189,12 @@ fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { /// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. #[test] fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { - for p in [json!({}), json!({"amount":25}), json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"})] { + for p in [ + json!({}), + json!({"amount":25}), + json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"}), + json!({"points":[[0,0],[0.009,0.125],[1,0]]}), + ] { let item = export_item("filter.distort.shear", &p, "S").unwrap(); let loaded = mapped_recording("Shr ", item.descriptor.unwrap()); assert!(loaded.unsupported.is_empty()); diff --git a/docs/atn.md b/docs/atn.md index e2dfb62d62..7373765540 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -1293,7 +1293,7 @@ Nested values as recorded (defaults): ### Group C: The Distort filters (Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave, ZigZag) -All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping is missing. Displace needs a displacement map file and is listed separately at the end. +The seven Distort events below map to PhotoCraft's `filter.distort.*` commands for their documented supported forms, including complete Shear curves. Displace needs a displacement map file and is listed separately at the end. #### Pinch @@ -1323,6 +1323,11 @@ Supported for complete curves: import clamps finite grid coordinates to the meas maps them to the native top-to-bottom position `(Vrtc - 1) / 127` and half-width offset `Hrzn / 64`, and preserves fractional recorded coordinates exactly on save. Both edge modes are supported. Only `ShrS` 0 and `ShrE` equal to the last point's index map; an omitted `ShrE` still defaults to 1. +The adapter accepts 2..4096 points as an execution safety budget, not a measured Photoshop format +maximum. This bounds point-vector allocation and the renderer's linear per-row curve search while +allowing fractional scripted points. Larger recorded curves stay intact as kept steps, and native +exports above that budget are refused before point conversion. No renderer performance is claimed. +The budget covers adapter mapping and playback; input parsing and generic export copies remain outside it. Malformed settings, unknown keys, nonfinite values, and curves whose order or points the native renderer would change remain complete kept steps. Export refuses unrepresentable native options and mask targets. The native Shear renderer and its ordinary command behavior are unchanged. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 56d04cdc13..3337645168 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (`.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. From 1f55de75c58a895622a5969688e3c98ac851c06b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 19:25:52 -0800 Subject: [PATCH 19/42] test(atn): cover nonfinite vertical Shear coordinates --- crates/engine/src/cmd/atn_cmds/tests.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e68775db90..f3454c774f 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -5887,9 +5887,15 @@ fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { /// Nonfinite recorded doubles remain unsupported before native parameters can be constructed. #[test] fn shear_nonfinite_coordinates_stay_kept() { + let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y))); + let finite = desc("null").with("ShrP", D::List(vec![point(-10000.0, -1000.0), point(10000.0, 1000.0)])); + assert!(matches!(steps_distort::shear_from(&finite), Mapped::Steps(_)), "finite coordinates still clamp and map"); for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y))); - let d = desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])); - assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. })); + for d in [ + desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])), + desc("null").with("ShrP", D::List(vec![point(0.0, 1.0), point(0.0, n)])), + ] { + assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. }), "{d:?}"); + } } } From 22ece6934e5543bacfd296ea21529d26965ed58f Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:08:15 +0100 Subject: [PATCH 20/42] Crop: check the kept pixels and the square's centroid in the Ctrl-held Straighten tests (#10870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Delete Cropped Pixels off, Reveal All brings back the turned image outside the frame (and with it on there is nothing to reveal). The 4° crop checks the red square's centroid instead of one pixel, which a crop off by up to about 19 px still covered. --- .../ui-egui/tests/it/crop_ctrl_straighten.rs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs index 3aa235e545..185d30bf55 100644 --- a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -126,7 +126,8 @@ fn a_command_held_straighten_line_keeps_the_frame_size() { // Where the turned frame's corners land on the canvas: the whole-canvas frame's // all leave it; the 500 × 500 one's top-right and bottom-left leave it at the top // and bottom, the other two stay inside the 800 px width. - let outside = turned_corners(frame, angle(&app)).map(|c| !(0.0..=800.0).contains(&c[0]) || !(0.0..=500.0).contains(&c[1])); + let turn = angle(&app); + let outside = turned_corners(frame, turn).map(|c| !(0.0..=800.0).contains(&c[0]) || !(0.0..=500.0).contains(&c[1])); let size = (frame[2] - frame[0], frame[3] - frame[1]); assert_eq!(committed(&mut app), (size.0 as u32, size.1 as u32), "{what}"); // The corners the turn uncovered take the background colour, the others and the @@ -137,6 +138,23 @@ fn a_command_held_straighten_line_keeps_the_frame_size() { assert_eq!(outside, if ratio.is_empty() { [true; 4] } else { [false, true, false, true] }, "{what}"); let mid = pixel(&app, (size.0 / 2.0) as usize, (size.1 / 2.0) as usize); assert!(is_white(mid), "{what}: the white image in the middle, not {mid:?}"); + // Delete Cropped Pixels off keeps the turned image outside the frame: Reveal All + // brings it back. Both frames are centred on the canvas's centre, so the revealed + // canvas is too. The point 300 px left of and 270 px above that centre (on the + // turned frame's axes) lies outside the frame and on the white image. + let q = [300.0, 270.0]; + let [on_image, ..] = turned_corners([400.0 - q[0], 250.0 - q[1], 400.0 + q[0], 250.0 + q[1]], turn); + assert!(on_image[0] > 10.0 && on_image[1] > 10.0 && on_image[0] < 270.0, "{what}: the point is on the image, at {on_image:?}"); + let revealed = app.run("image.revealAll", json!({})).expect("reveal all"); + let doc_size = app.session.active().expect("document").doc.size; + let (w, h) = (f64::from(doc_size.width), f64::from(doc_size.height)); + if delete { + assert_eq!((revealed["changed"].clone(), w, h), (json!(false), size.0, size.1), "{what}: nothing left to reveal"); + } else { + assert!(w > size.0 + 40.0 && h > size.1 + 40.0, "{what}: revealed {w} × {h}"); + let p = pixel(&app, (w / 2.0 - q[0]) as usize, (h / 2.0 - q[1]) as usize); + assert!(is_white(p), "{what}: the image outside the frame is kept, not {p:?}"); + } } } } @@ -162,9 +180,20 @@ fn a_command_held_line_keeps_a_drawn_frame_where_it_is() { assert!((angle(&app) - 4.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: after the second line"); assert_eq!(committed(&mut app), (400, 300), "classic {classic}"); - // The crop is centred on the red square, where the frame was; turned 4° the frame stays - // inside the image, so nothing is filled. - assert!(is_red(pixel(&app, 200, 150)), "classic {classic}: the square in the middle, not {:?}", pixel(&app, 200, 150)); + // The crop is centred on the red square, where the frame was (its red pixels' centroid + // is the middle, which one pixel in the 40 px square can't tell); turned 4° the frame + // stays inside the image, so nothing is filled. + let flat = photocraft_compose::flatten(&app.session.active().expect("document").doc); + let red: Vec<(f64, f64)> = (0..300usize) + .flat_map(|y| (0..400usize).map(move |x| (x, y))) + .filter(|&(x, y)| is_red(flat.px[y * 400 + x])) + .map(|(x, y)| (x as f64, y as f64)) + .collect(); + assert!(red.len() > 1000, "classic {classic}: the square is in the crop ({} red pixels)", red.len()); + let n = red.len() as f64; + let (cx, cy) = (red.iter().map(|p| p.0).sum::() / n, red.iter().map(|p| p.1).sum::() / n); + // Pixel centres sit at +0.5, so the middle of 400 × 300 is (199.5, 149.5). + assert!((cx - 199.5).abs() <= 1.0 && (cy - 149.5).abs() <= 1.0, "classic {classic}: the red centroid is ({cx:.1}, {cy:.1})"); for p in corners(&app) { assert!(is_white(p), "classic {classic}: a corner is {p:?}"); } From 34c49d575b110f65c348cbf789da4550620d386a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 22:00:11 -0800 Subject: [PATCH 21/42] Tests: exercise recorded layer-style actions against native output --- crates/engine/tests/it/atn_layer_style.rs | 327 ++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 2 files changed, 328 insertions(+) create mode 100644 crates/engine/tests/it/atn_layer_style.rs diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs new file mode 100644 index 0000000000..fe75313810 --- /dev/null +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -0,0 +1,327 @@ +//! Measured layer-style action steps retain their settings and use the existing native commands. +use photocraft_doc::{Document, LayerId}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +const CREATE: &str = "layer.layerStyle.createLayer"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::default(), class_id: Id::new(id) } +} + +fn scale(value: f64) -> Descriptor { + Descriptor::new("null").with("Scl ", D::UnitFloat { unit: *b"#Prc", value }) +} + +fn create() -> Descriptor { + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +fn item(id: &str, descriptor: Descriptor) -> AtnItem { + let (event, name) = if id == SCALE { (AtnEvent::name("scaleEffectsEvent"), "Scale Effects") } else { (AtnEvent::Code(*b"Mk "), "Make") }; + AtnItem { event, dictionary: name.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() } +} + +fn file(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + } +} + +fn action(items: Vec) -> Action { + let loaded = import(&atn::write(&file(items)).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(id: &str, params: Value) -> Option { + let a = Action::new("A", vec![(id.into(), params)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// A small painted source with two effects makes a real conversion differ from a blank layer. +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Source"})).unwrap(); + let source = s.active().unwrap().active_layer.unwrap(); + s.edit("Synthetic pixels", |doc, _| { + let pixels: Vec = + (0..8).flat_map(|y| (0..8).flat_map(move |x| if (2..6).contains(&x) && (2..6).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] })).collect(); + let bounds = doc.bounds(); + doc.layer_mut(source).unwrap().surface_mut().unwrap().write_region(bounds, &pixels); + Ok(()) + }) + .unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"blend":"normal","distance":1,"size":1,"opacity":60})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color":"#ff8000","opacity":50,"add":true})).unwrap(); + (s, source) +} + +/// Compare each complete raster and its style/stack state without assuming cross-session IDs. +fn equivalent(actual: &Session, expected: &Session) { + let a = &actual.active().unwrap().doc; + let b = &expected.active().unwrap().doc; + assert_eq!(a.layers.len(), b.layers.len()); + assert_eq!(a.selection, b.selection); + for (a, b) in a.layers.iter().zip(&b.layers) { + assert_eq!((&a.name, a.clipped, a.blend, a.opacity), (&b.name, b.clipped, b.blend, b.opacity)); + assert_eq!(a.effects, b.effects); + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + assert_eq!(a.content_bounds(), b.content_bounds()); + let bounds = a.content_bounds().union(&b.content_bounds()); + assert_eq!(a.read_region(bounds), b.read_region(bounds)); + } +} + +/// Undo exactly the native control's history edits, then redo the complete document state. +fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) { + for _ in 0..edits { + assert!(s.undo()); + } + assert_eq!(&s.active().unwrap().doc, before); + for _ in 0..edits { + assert!(s.redo()); + } + assert_eq!(&s.active().unwrap().doc, after); +} + +/// Playback must produce actual effect layers, not the blank layer the old Make branch creates. +#[test] +fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let (mut native, native_source) = fixture(depth); + let native_before = native.active().unwrap().doc.clone(); + native.execute(CREATE, json!({})).unwrap(); + let native_after = native.active().unwrap().doc.clone(); + assert_eq!(native_after.layers.len(), native_before.layers.len() + 2); + assert!(native_after.layer(native_source).unwrap().effects.items.is_empty()); + undo_redo(&mut native, &native_before, &native_after, 1); + + let (mut recorded, source) = fixture(depth); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 2, "{depth}: Create Layers must split two effects, not create one blank layer"); + assert!(after.layer(source).unwrap().effects.items.is_empty()); + let at = after.layers.iter().position(|l| l.id == source).unwrap(); + assert!(after.layers[at - 1].name.ends_with("Drop Shadow") && !after.layers[at - 1].clipped); + assert!(after.layers[at + 1].name.ends_with("Color Overlay") && after.layers[at + 1].clipped); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } +} + +/// The recorded whole-percent limits and interior values match native effect scaling at all depths. +#[test] +fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + for percent in [1.0, 100.0, 200.0, 1000.0] { + let (mut native, _) = fixture(depth); + let native_past = native.active().unwrap().history.past_len(); + native.execute(SCALE, json!({"scale":percent})).unwrap(); + let edits = native.active().unwrap().history.past_len() - native_past; + let (mut recorded, _) = fixture(depth); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(percent))])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(recorded.active().unwrap().history.past_len(), past + edits); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, edits); + } + } +} + +/// Scaling then splitting keeps the same native result and two separately reversible edits. +#[test] +fn scale_then_create_layers_matches_native_sequence() { + let (mut native, _) = fixture(16); + native.execute(SCALE, json!({"scale":200})).unwrap(); + native.execute(CREATE, json!({})).unwrap(); + let (mut recorded, _) = fixture(16); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 2); + assert!(result.get("failed").is_none()); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 2); + let after = recorded.active().unwrap().doc.clone(); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 2); +} + +/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value. +#[test] +fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() { + for (id, mut d) in [(SCALE, scale(100.0)), (CREATE, create())] { + d.name = UnicodeString::new_nul("Recorded style step"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(id, d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let a = action(vec![source.clone()]); + assert_eq!(a.steps[0].0, id); + let bytes = export("S", &[&a], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![source]); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnLayerStyleDescriptor"] = json!("not base64"); + assert!(exported(id, damaged).is_none()); + } + let mut d = scale(100.0); + d.name = UnicodeString::new_nul("Original"); + let mut a = action(vec![item(SCALE, d)]); + a.steps[0].1["scale"] = json!(200); + for skip in [false, true] { + let (bytes, skipped) = export("S", &[&a], &[], skip).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(scale(200.0))); + } +} + +/// Native defaults are explicit; unrepresented targets, options and damaged metadata cannot vanish. +#[test] +fn layer_style_native_exports_are_strict_and_make_defaults_explicit() { + assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0))); + assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(create())); + for percent in [1, 100, 200, 1000] { + assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent)))); + } + for value in [json!(0), json!(1001), json!(1.5), Value::Null, json!("100"), json!(true)] { + assert!(exported(SCALE, json!({"scale":value})).is_none()); + } + for id in [SCALE, CREATE] { + for key in ["layer", "layers", "target", "unknown"] { + let mut p = json!({}); + p[key] = json!(1); + assert!(exported(id, p).is_none(), "{id}/{key}"); + } + let invalid = if id == SCALE { scale(100.0) } else { create() }.with("extra", D::Boolean(true)); + let mut p = if id == SCALE { json!({"scale":200}) } else { json!({}) }; + p["_atnLayerStyleDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); + assert!(exported(id, p).is_none()); + } + assert!(exported(CREATE, json!({"scale":100})).is_none()); +} + +/// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation. +#[test] +fn layer_style_malformed_records_stay_kept_without_an_edit() { + let mut cases = vec![(SCALE, Descriptor::new("null")), (CREATE, Descriptor::new("null").with("Usng", create().items[1].1.clone()))]; + for value in [D::Integer(100), D::Double(100.0), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 100.0 }] { + cases.push((SCALE, Descriptor::new("null").with("Scl ", value))); + } + for value in [0.0, 1001.0, 1.5, f64::NAN, f64::INFINITY] { + cases.push((SCALE, scale(value))); + } + for (id, d) in [(SCALE, scale(100.0)), (CREATE, create())] { + cases.push((id, d.clone().with("extra", D::Boolean(true)))); + let mut duplicate = d.clone(); + duplicate.items.push(duplicate.items[0].clone()); + cases.push((id, duplicate)); + } + for null in [ + D::Integer(1), + D::Reference(vec![ReferenceItem::Class(class("Chnl"))]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + ] { + let mut d = create(); + d.items[0].1 = null; + cases.push((CREATE, d)); + } + for using in [ + D::Integer(1), + D::Boolean(false), + D::Descriptor(Descriptor::new("unknown")), + D::Reference(vec![]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("wrong") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("wrong"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") }, + ]), + ] { + let mut d = create(); + d.items[1].1 = using; + cases.push((CREATE, d)); + } + for (id, d) in cases { + let source = item(id, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![source]); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx. +#[test] +fn ordinary_layer_make_remains_a_legitimate_control() { + for using in [None, Some(D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary")))))] { + let mut d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])); + if let Some(u) = using { + d = d.with("Usng", u); + } + let a = action(vec![item(CREATE, d)]); + assert_eq!(a.steps[0].0, "layer.new.layer"); + let (mut s, source) = fixture(8); + let before = s.active().unwrap().doc.clone(); + s.actions.list.push(a); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + undo_redo(&mut s, &before, &after, 1); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 467b04720d..c194898f13 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -3,6 +3,7 @@ mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; +mod atn_layer_style; mod atn_reference_forms; mod atn_ripple_amount; mod atn_selective_color; From 3271b4acf03e801748f8589f203ad8d6a383d7a5 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 22:03:52 -0800 Subject: [PATCH 22/42] Actions: map Scale Effects and Create Layers recordings --- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 3 + .../src/cmd/atn_cmds/steps_layer_style.rs | 105 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/steps_make.rs | 6 + crates/engine/src/cmd/atn_cmds/tests.rs | 2 + docs/atn.md | 28 ++++- 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_layer_style.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 47f4b688fb..151232dcd9 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -39,6 +39,7 @@ mod steps_doc; mod steps_gallery; mod steps_image; mod steps_layer; +mod steps_layer_style; mod steps_make; mod steps_stylize; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index a29b4a1957..3860e8c333 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -20,6 +20,7 @@ use super::steps_layer::{ move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, }; +use super::steps_layer_style::{scale_from, style_to}; use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; @@ -595,12 +596,14 @@ pub(super) const EVENTS: &[Event] = &[ }, Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to }, // Layers. + Event { code: "", name: "scaleEffectsEvent", label: "Scale Effects", ids: &["layer.layerStyle.scaleEffects"], from: scale_from, to: style_to }, Event { code: "Mk ", name: "make", label: "Make", ids: &[ "layer.new.layer", + "layer.layerStyle.createLayer", "layer.new.group", "layer.groupLayers", "layer.duplicate", diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs new file mode 100644 index 0000000000..dd99890b24 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -0,0 +1,105 @@ +//! The measured Scale Effects event and Make-from-layer-effects form. +use super::descriptors::{PCT, class, class_ref, desc, get_ref, jstr, pct, ref_class}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +pub(super) const CREATE: &str = "layer.layerStyle.createLayer"; +const SOURCE: &str = "_atnLayerStyleDescriptor"; + +/// Only documented keys, once each: unknown or repeated settings cannot be discarded. +fn keys(d: &Descriptor, expected: &[&str]) -> bool { + d.items.len() == expected.len() && expected.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) +} + +/// The dialog records whole percentages; native fractional/clamped values are not guessed. +fn whole_percent(value: f64) -> bool { + value.is_finite() && (1.0..=1000.0).contains(&value) && value.fract() == 0.0 +} + +/// The native Create Layers operation has only the measured implicit active-layer target. +fn create_descriptor() -> Descriptor { + desc("null").with("null", class_ref("Lyr ")).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +/// Parse the exact recorded values and reference chain, keeping unsupported forms complete. +fn parameters(id: &str, d: &Descriptor) -> Option { + if id == SCALE { + if !keys(d, &["Scl "]) { + return None; + } + let D::UnitFloat { unit, value } = d.get("Scl ")? else { return None }; + return (*unit == PCT && whole_percent(*value)).then(|| json!({"scale":value})); + } + if id != CREATE || !keys(d, &["null", "Usng"]) { + return None; + } + let [ReferenceItem::Class(layer)] = get_ref(d, "null")? else { return None }; + let [ReferenceItem::Property { class: property, key }, ReferenceItem::Enumerated { class: target, type_id, value }] = get_ref(d, "Usng")? else { + return None; + }; + (layer.class_id.is("Lyr ") && property.class_id.is("Prpr") && key.is("Lefx") && target.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Trgt")) + .then(|| json!({})) +} + +/// Build supported native values explicitly; an absent scale uses the native default of 100. +fn descriptor(id: &str, p: &Value) -> Option { + let object = p.as_object()?; + if object.keys().any(|key| key != SOURCE && !(id == SCALE && key == "scale")) { + return None; + } + match id { + SCALE => { + let value = match p.get("scale") { + None => 100.0, + Some(v) => v.as_f64()?, + }; + whole_percent(value).then(|| desc("null").with("Scl ", pct(value))) + } + CREATE => Some(create_descriptor()), + _ => None, + } +} + +/// Preserve wire identity/order while unchanged, without making it a native option. +fn from(id: &str, d: &Descriptor) -> Mapped { + let Some(mut p) = parameters(id, d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(id, &p).as_ref() != Some(d) { + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(id, p) +} + +/// Import Scale Effects only when the whole recorded percentage is represented. +pub(super) fn scale_from(d: &Descriptor) -> Mapped { + from(SCALE, d) +} + +/// Intercept layer-style Make intent before generic Make can turn it into a blank layer. +pub(super) fn create_from_descriptor(d: &Descriptor) -> Option { + let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr"); + let style_intent = d.items.iter().filter(|(key, _)| key.is("Usng")).any(|(_, value)| { + let style_reference = matches!(value, D::Reference(r) if r.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx")))); + // A layer Make may omit Usng, or use the ordinary layer-options object. Other + // shapes must not be discarded as if they were an empty layer-options object. + style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr "))) + }); + style_intent.then(|| from(CREATE, d)) +} + +/// Validate preserved data before fallback; supported edits export their current canonical values. +pub(super) fn style_to(id: &str, p: &Value) -> Option { + let canonical = descriptor(id, p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + let original = descriptor(id, ¶meters(id, &source)?)?; + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 07c71040a5..f5a19a72e9 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -174,6 +174,9 @@ const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mer /// Import Make events only when their geometry and options can be represented faithfully. pub(super) fn make_from(d: &Descriptor) -> Mapped { + if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) { + return mapped; + } if let Some(mapped) = duplicate_layer_from_descriptor(d) { return mapped; } @@ -302,6 +305,9 @@ fn recorded_percent(opacity: f64) -> f64 { /// Write a creation step, refusing native options the target descriptor cannot carry. pub(super) fn make_to(id: &str, p: &Value) -> Option { + if id == super::steps_layer_style::CREATE { + return super::steps_layer_style::style_to(id, p); + } let with_name = |cls: &str| { let mut u = desc(cls); if let Some(n) = jstr(p, "name") { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f153ee83af..50dfa6520a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -714,6 +714,8 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.createClippingMask", json!({})), ("layer.ungroupLayers", json!({})), ("layer.layerStyle.copyLayerStyle", json!({})), + ("layer.layerStyle.scaleEffects", json!({"scale": 200})), + ("layer.layerStyle.createLayer", json!({})), ("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})), ("layer.setExpanded", json!({"all": true, "expanded": false})), ("layerComp.apply", json!({"comp": "Original"})), diff --git a/docs/atn.md b/docs/atn.md index 99f883fc98..6042c7664a 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -18,7 +18,7 @@ layers and documents, newer and older events and files, and a measured reference map yet, with everything needed to implement them without the reference application. -## Supported events (144) +## Supported events (145) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -97,7 +97,7 @@ the reference application. | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | -| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | +| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | @@ -147,6 +147,7 @@ the reference application. | `Shw ` | show | Show | `layer.showLayers`, `layer.showOnly` | | `Smlr` | similar | Similar | `select.similar` | | (string ID only) | selectSky | Sky | `select.sky` | +| (string ID only) | scaleEffectsEvent | Scale Effects | `layer.layerStyle.scaleEffects` | | `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | | (string ID only) | smartSharpen | Smart Sharpen | `filter.sharpen.smartSharpen` | | `Smth` | smoothness | Smooth | `select.modify.smooth` | @@ -167,6 +168,27 @@ the reference application. | `Wave` | wave | Wave | `filter.distort.wave` | | `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | +### Layer Style menu recordings + +`scaleEffectsEvent` maps to `layer.layerStyle.scaleEffects` with the recorded `Scl ` +whole percentage in `#Prc`, from 1 through 1000. A missing recorded value is not +given a guessed default. Native export makes its effective default of 100 explicit; +fractional, out-of-range and unrepresented options refuse export. + +Create Layer(s) maps Make (`Mk ` / `make`) to `layer.layerStyle.createLayer` when +`null` is the `Lyr ` class reference and `Usng` is the `Lefx` property of the target +layer (`Prpr.Lefx`, `Lyr .Ordn.Trgt`). It has no recorded operation settings. +The existing native command creates the effect layers. The warning dialog is not +an extra action parameter. Explicit native layer IDs have no measured export form. +Malformed references stay complete kept steps, rather than creating a blank layer; +ordinary layer creation still uses its layer-options object or class-only form. + +Both mappings retain descriptor identity, order and step flags while unchanged. +A supported scale edit exports its new value canonically; damaged preservation +metadata remains refused. These two rows are the implemented subset of #10723; +the other menu recordings remain separate work. Playback reuses native history +and rendering; this does not claim Photoshop pixel equivalence. + ### Settings retained by mapped events - Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to @@ -366,8 +388,6 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name. | Menu | Recorded step | Keys | |---|---|---| -| Layer > Layer Style > Scale Effects | `scaleEffectsEvent` | `Scl ` percent, whole number 1..1000 (the dialog clamps) | -| Layer > Layer Style > Create Layer(s) | Make, `null` = class `Lyr `, `Usng` = reference to the `Lefx` property of the target layer | none; the dialog first warns that some effects cannot be reproduced with layers (OK / Cancel) | | Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded | | Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | | | Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | | From 0de587b502648c0944ddf09b1b5b8d27a74d9277 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 22:15:50 -0800 Subject: [PATCH 23/42] Tests: correct layer-style document and wire oracles --- crates/engine/tests/it/atn_layer_style.rs | 56 ++++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index fe75313810..17a2e627da 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -1,5 +1,5 @@ //! Measured layer-style action steps retain their settings and use the existing native commands. -use photocraft_doc::{Document, LayerId}; +use photocraft_doc::{Document, Effect, LayerId}; use photocraft_engine::{ Session, actions_cmds::Action, @@ -57,6 +57,32 @@ fn exported(id: &str, params: Value) -> Option { atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() } +/// Wire bytes also compare preserved NaN payloads, whose floating values are not reflexively equal. +fn same_item_wire(mut actual: AtnItem, mut expected: AtnItem) { + assert_eq!(actual.descriptor.take().map(|d| d.to_bytes()), expected.descriptor.take().map(|d| d.to_bytes())); + assert_eq!(actual, expected); +} + +/// The direct control must actually scale the known one-pixel shadow, not merely accept the call. +fn scaled_shadow(s: &Session, source: LayerId, percent: f64) { + let shadow = s + .active() + .unwrap() + .doc + .layer(source) + .unwrap() + .effects + .items + .iter() + .find_map(|effect| match effect { + Effect::DropShadow(shadow) => Some(shadow), + _ => None, + }) + .unwrap(); + let factor = percent as f32 / 100.0; + assert_eq!((shadow.distance, shadow.size), (factor, factor)); +} + /// A small painted source with two effects makes a real conversion differ from a blank layer. fn fixture(depth: u32) -> (Session, LayerId) { let mut s = Session::new(); @@ -97,11 +123,11 @@ fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) for _ in 0..edits { assert!(s.undo()); } - assert_eq!(&s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().doc.as_ref(), before); for _ in 0..edits { assert!(s.redo()); } - assert_eq!(&s.active().unwrap().doc, after); + assert_eq!(s.active().unwrap().doc.as_ref(), after); } /// Playback must produce actual effect layers, not the blank layer the old Make branch creates. @@ -140,9 +166,10 @@ fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() { for depth in [8, 16, 32] { for percent in [1.0, 100.0, 200.0, 1000.0] { - let (mut native, _) = fixture(depth); + let (mut native, native_source) = fixture(depth); let native_past = native.active().unwrap().history.past_len(); native.execute(SCALE, json!({"scale":percent})).unwrap(); + scaled_shadow(&native, native_source, percent); let edits = native.active().unwrap().history.past_len() - native_past; let (mut recorded, _) = fixture(depth); let before = recorded.active().unwrap().doc.clone(); @@ -192,7 +219,9 @@ fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() { let a = action(vec![source.clone()]); assert_eq!(a.steps[0].0, id); let bytes = export("S", &[&a], &[], false).unwrap().0; - assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![source]); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); let mut damaged = a.steps[0].1.clone(); damaged["_atnLayerStyleDescriptor"] = json!("not base64"); assert!(exported(id, damaged).is_none()); @@ -279,6 +308,19 @@ fn layer_style_malformed_records_stay_kept_without_an_edit() { ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") }, ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ReferenceItem::Class(class("Lyr ")), + ]), ] { let mut d = create(); d.items[1].1 = using; @@ -290,7 +332,9 @@ fn layer_style_malformed_records_stay_kept_without_an_edit() { assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}"); assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; - assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![source]); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); let (mut s, _) = fixture(8); let before = s.active().unwrap().doc.clone(); let past = s.active().unwrap().history.past_len(); From da54cec641cd0d07004b836908fd4b48fe914077 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 22:23:16 -0800 Subject: [PATCH 24/42] Tests: match the canonical layer-style reference names --- crates/engine/tests/it/atn_layer_style.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index 17a2e627da..8ede4766cd 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -241,7 +241,20 @@ fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() { #[test] fn layer_style_native_exports_are_strict_and_make_defaults_explicit() { assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0))); - assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(create())); + let mut canonical_create = create(); + // The shared canonical class constructor writes nul-terminated display names; + // imported empty names remain separate wire data in the round-trip fixtures. + for (_, value) in &mut canonical_create.items { + let D::Reference(parts) = value else { panic!("fixture must contain references") }; + for part in parts { + let class = match part { + ReferenceItem::Class(class) | ReferenceItem::Property { class, .. } | ReferenceItem::Enumerated { class, .. } => class, + _ => panic!("fixture must contain class/property/target references"), + }; + class.name = UnicodeString::new_nul(""); + } + } + assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(canonical_create)); for percent in [1, 100, 200, 1000] { assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent)))); } From 66d067d30d40942d6e2cd129c79591f808226055 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 23:00:36 -0800 Subject: [PATCH 25/42] Tests: play a miskeyed effects reference before checking retention --- crates/engine/tests/it/atn_layer_style.rs | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index 8ede4766cd..f39bba0b24 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -360,6 +360,36 @@ fn layer_style_malformed_records_stay_kept_without_an_edit() { } } +/// A miskeyed effects reference must be kept, rather than playing as an ordinary blank layer. +#[test] +fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() { + let mut d = create(); + d.items[1].0 = Id::new("Usin"); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Play before checking import classification: the old path actually creates a blank layer. + assert!( + s.active().unwrap().doc == before, + "miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), s.active().unwrap().doc.layers.len(), past, s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); +} + /// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx. #[test] fn ordinary_layer_make_remains_a_legitimate_control() { From 35dbf2a1bc02df07b54d2605936143e248f56e52 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 23:01:31 -0800 Subject: [PATCH 26/42] Actions: keep miskeyed effects references intact --- crates/engine/src/cmd/atn_cmds/steps_layer_style.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs index dd99890b24..5b08e13118 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -84,11 +84,16 @@ pub(super) fn scale_from(d: &Descriptor) -> Mapped { /// Intercept layer-style Make intent before generic Make can turn it into a blank layer. pub(super) fn create_from_descriptor(d: &Descriptor) -> Option { let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr"); - let style_intent = d.items.iter().filter(|(key, _)| key.is("Usng")).any(|(_, value)| { + let style_intent = d.items.iter().any(|(item_key, value)| { let style_reference = matches!(value, D::Reference(r) if r.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx")))); - // A layer Make may omit Usng, or use the ordinary layer-options object. Other - // shapes must not be discarded as if they were an empty layer-options object. - style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr "))) + if item_key.is("Usng") { + // A layer Make may omit Usng, or use the ordinary layer-options object. Other + // shapes must not be discarded as if they were an empty layer-options object. + style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr "))) + } else { + // A miskeyed effects reference is still style intent, not an ordinary blank layer. + layer_make && style_reference + } }); style_intent.then(|| from(CREATE, d)) } From d74288d7ead183d95d57fe7e86e9f8a040b2433e Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 23:16:42 -0800 Subject: [PATCH 27/42] Style: wrap the malformed effects playback assertion --- crates/engine/tests/it/atn_layer_style.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index f39bba0b24..fbd2870814 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -376,7 +376,10 @@ fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() { assert!( s.active().unwrap().doc == before, "miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}", - before.layers.len(), s.active().unwrap().doc.layers.len(), past, s.active().unwrap().history.past_len() + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() ); assert_eq!(s.active().unwrap().history.past_len(), past); assert_eq!(result["ran"], 0); From 5a768ded2c7a239d41da18f19a43643a06f789ac Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 00:08:41 -0800 Subject: [PATCH 28/42] test(atn): retain nested layer-effects references during playback --- crates/engine/tests/it/atn_layer_style.rs | 86 ++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index fbd2870814..d62394784b 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -7,7 +7,7 @@ use photocraft_engine::{ }; use photocraft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, + descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D}, }; use serde_json::{Value, json}; @@ -415,3 +415,87 @@ fn ordinary_layer_make_remains_a_legitimate_control() { undo_redo(&mut s, &before, &after, 1); } } + +/// Every typed container must retain nested effects intent without changing the document or history. +#[test] +fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { + let reference = create().items.pop().unwrap().1; + let nested = Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Before"))).with("data", reference.clone()); + let mut deepest = reference.clone(); + // Root descriptor depth0, Usng value depth1, options descriptor depth2, data value depth3. + for _ in 3..MAX_DEPTH { + deepest = D::List(vec![D::Boolean(false), deepest]); + } + let cases = [ + ("reference", reference.clone()), + ("descriptor", D::Descriptor(nested.clone())), + ("global object", D::GlobalObject(nested.clone())), + ("list", D::List(vec![D::Boolean(false), reference])), + ("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })), + ( + "mixed containers", + D::List(vec![ + D::Boolean(false), + D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested }))), + ]), + ), + ("parser depth limit", deepest), + ]; + for (label, value) in cases { + for key in ["Usng", "Usin"] { + let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with( + key, + D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone())), + ); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Playback precedes classification so the baseline exposes the actual blank-layer edit. + assert!( + s.active().unwrap().doc == before, + "{label}/{key}: nested Lefx changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past, "{label}/{key}"); + assert_eq!(result["ran"], 0, "{label}/{key}"); + assert!(result.get("failed").is_some(), "{label}/{key}"); + assert_eq!(loaded.unsupported.len(), 1, "{label}/{key}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep", "{label}/{key}"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty(), "{label}/{key}"); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1, "{label}/{key}"); + same_item_wire(saved.into_iter().next().unwrap(), source); + } + } +} + +/// Native-exported layer options, including a name equal to Lefx, retain their actual settings. +#[test] +fn ordinary_layer_make_native_export_matches_native_playback() { + let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true}); + let a = action(vec![exported("layer.new.layer", params.clone()).unwrap()]); + assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone())); + let (mut native, _) = fixture(8); + native.execute("layer.new.layer", params).unwrap(); + let (mut recorded, source) = fixture(8); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(a); + let result = recorded.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); +} From 6035bd7b175500052963ecd8312cdfc7e90e3b4a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 00:09:36 -0800 Subject: [PATCH 29/42] test(atn): distinguish opaque metadata from typed effects references --- crates/engine/tests/it/atn_layer_style.rs | 49 ++++++++++++++--------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index d62394784b..0431056453 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -477,25 +477,38 @@ fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { } } -/// Native-exported layer options, including a name equal to Lefx, retain their actual settings. +/// Native layer options still play with Lefx as a name or in synthetic opaque metadata. #[test] fn ordinary_layer_make_native_export_matches_native_playback() { let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true}); - let a = action(vec![exported("layer.new.layer", params.clone()).unwrap()]); - assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone())); - let (mut native, _) = fixture(8); - native.execute("layer.new.layer", params).unwrap(); - let (mut recorded, source) = fixture(8); - let before = recorded.active().unwrap().doc.clone(); - let past = recorded.active().unwrap().history.past_len(); - recorded.actions.list.push(a); - let result = recorded.execute("actions.play", json!({"action": 0})).unwrap(); - assert_eq!(result["ran"], 1); - assert!(result.get("failed").is_none()); - let after = recorded.active().unwrap().doc.clone(); - assert_eq!(after.layers.len(), before.layers.len() + 1); - assert_eq!(after.layer(source), before.layer(source)); - assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); - equivalent(&recorded, &native); - undo_redo(&mut recorded, &before, &after, 1); + let opaque = D::List(vec![ + D::RawData(create().to_bytes()), + D::Alias(b"Lefx".to_vec()), + D::Path(b"Lefx".to_vec()), + D::Text(UnicodeString::new_nul("Lefx")), + ]); + for metadata in [None, Some(opaque)] { + let mut source = exported("layer.new.layer", params.clone()).unwrap(); + if let Some(value) = metadata { + let D::Descriptor(options) = &mut source.descriptor.as_mut().unwrap().items[1].1 else { panic!("native layer options") }; + options.items.push((Id::new("data"), value)); + } + let a = action(vec![source]); + assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone())); + let (mut native, _) = fixture(8); + native.execute("layer.new.layer", params.clone()).unwrap(); + let (mut recorded, source) = fixture(8); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(a); + let result = recorded.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } } From 9f8b880e2bf993b83cc776c6639692c40665839a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 00:10:26 -0800 Subject: [PATCH 30/42] fix(atn): retain nested layer-effects Make intent --- .../src/cmd/atn_cmds/steps_layer_style.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs index 5b08e13118..4b85127cf4 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -81,11 +81,31 @@ pub(super) fn scale_from(d: &Descriptor) -> Mapped { from(SCALE, d) } +/// Find typed effects references in parsed values; opaque data and class names are not references. +fn has_style_reference(value: &D) -> bool { + let mut pending = vec![value]; + while let Some(value) = pending.pop() { + match value { + D::Reference(parts) => { + if parts.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx"))) { + return true; + } + } + D::Descriptor(d) | D::GlobalObject(d) => pending.extend(d.items.iter().map(|(_, value)| value)), + D::List(values) => pending.extend(values), + D::ObjectArray(array) => pending.extend(array.body.items.iter().map(|(_, value)| value)), + _ => {} + } + } + false +} + /// Intercept layer-style Make intent before generic Make can turn it into a blank layer. pub(super) fn create_from_descriptor(d: &Descriptor) -> Option { let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr"); let style_intent = d.items.iter().any(|(item_key, value)| { - let style_reference = matches!(value, D::Reference(r) if r.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx")))); + // Deep inspection belongs to Layer Make; other Make classes retain direct-reference handling. + let style_reference = (layer_make || matches!(value, D::Reference(_))) && has_style_reference(value); if item_key.is("Usng") { // A layer Make may omit Usng, or use the ordinary layer-options object. Other // shapes must not be discarded as if they were an empty layer-options object. From 65b38308574ea50e05f16dd8a7245eb4affcc462 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 00:43:31 -0800 Subject: [PATCH 31/42] style(atn): format nested effects regression tests --- crates/engine/tests/it/atn_layer_style.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index 0431056453..38db75621e 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -434,19 +434,15 @@ fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { ("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })), ( "mixed containers", - D::List(vec![ - D::Boolean(false), - D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested }))), - ]), + D::List(vec![D::Boolean(false), D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested })))]), ), ("parser depth limit", deepest), ]; for (label, value) in cases { for key in ["Usng", "Usin"] { - let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with( - key, - D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone())), - ); + let d = Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])) + .with(key, D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone()))); let source = item(CREATE, d); let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); let (mut s, _) = fixture(8); @@ -481,12 +477,7 @@ fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { #[test] fn ordinary_layer_make_native_export_matches_native_playback() { let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true}); - let opaque = D::List(vec![ - D::RawData(create().to_bytes()), - D::Alias(b"Lefx".to_vec()), - D::Path(b"Lefx".to_vec()), - D::Text(UnicodeString::new_nul("Lefx")), - ]); + let opaque = D::List(vec![D::RawData(create().to_bytes()), D::Alias(b"Lefx".to_vec()), D::Path(b"Lefx".to_vec()), D::Text(UnicodeString::new_nul("Lefx"))]); for metadata in [None, Some(opaque)] { let mut source = exported("layer.new.layer", params.clone()).unwrap(); if let Some(value) = metadata { From 4777b390c9bbfb8cc8f882b8f2343f11930cfdc8 Mon Sep 17 00:00:00 2001 From: jayzed80 Date: Tue, 3 Nov 2026 09:16:51 +0000 Subject: [PATCH 32/42] fix(algo): Filter Gallery draws pixels from the same clamped values as its reach The tile halo was sized from the clamped effect values, but the pixel code still read the raw stored ones, so a stored value past its range could have a kernel larger than its halo and show seams where tiles meet. `run` and `Prepared::new` now clamp the stack once at the entry, so the reach and the pixels use the same values. The new test renders every gallery filter with its range values at three times the maximum and requires the same pixels as at the maximum; it fails without the change (ColoredPencil first). Addresses the CodeRabbit review on this PR. Co-Authored-By: Claude Sonnet 5.5 --- crates/algo/src/artistic_fx.rs | 7 ++-- crates/algo/tests/it/gallery_reach_extreme.rs | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 6bda692017..a2c98e3c39 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -542,8 +542,9 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx) return src.crop(out); } let mut win = Win { r, w, h, c: src.data.chunks_exact(n).map(|px| rgb(rgba(ctx, px))).collect() }; - for e in effects { - apply(e, &mut win, ctx); + // The reach that sized `src` comes from the clamped values, so the pixels must use them too. + for e in effects.iter().map(GalleryEffect::clamped) { + apply(&e, &mut win, ctx); } finish(&win, src, out, ctx) } @@ -564,6 +565,8 @@ pub(crate) struct Prepared { impl Prepared { /// `None` for a stack that has no Cutout (it runs tile by tile as usual). pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option { + // Held to the declared ranges once here (see `run`), so the traced shapes and the tiles agree. + let effects = &effects.iter().map(GalleryEffect::clamped).collect::>(); let last = effects.iter().rposition(|e| e.filter == F::Cutout)?; let r = src.rect; let (w, h) = (r.width() as usize, r.height() as usize); diff --git a/crates/algo/tests/it/gallery_reach_extreme.rs b/crates/algo/tests/it/gallery_reach_extreme.rs index 4e52dc3501..32d4356267 100644 --- a/crates/algo/tests/it/gallery_reach_extreme.rs +++ b/crates/algo/tests/it/gallery_reach_extreme.rs @@ -1,7 +1,10 @@ //! A project file stores a Filter Gallery effect's values as plain numbers, bypassing the clamp that //! `GalleryEffect::set` applies, so the reach arithmetic must survive any `f32` in them. -use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo}; +use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; const EXTREME: [f32; 6] = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, 3.0e9]; @@ -41,3 +44,33 @@ fn gallery_reach_of_a_stack_survives_extreme_stored_values() { assert!(r >= 0, "{filter:?} stacked three times: reach {r}"); } } + +/// Every range value at `scale` times its declared maximum (past the range when `scale` > 1). +fn scaled_to_max(filter: GalleryFilter, scale: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if let GalleryParamKind::Range { max, .. } = p.kind { + e.values.insert(p.key.to_string(), max * scale); + } + } + e +} + +/// The tile halo comes from the clamped values, so the pixels must be drawn from them too: a stored +/// value past its range renders exactly as the clamped one, with no seam where tiles meet. +#[test] +fn gallery_pixels_use_the_same_clamped_values_as_the_reach() { + let mut src = Surface::new(PixelFormat::RGBA8); + src.fill_rect(Rect::new(0, 0, 160, 160), &[0.2, 0.5, 0.8, 1.0]); + src.fill_rect(Rect::new(30, 40, 120, 100), &[0.9, 0.3, 0.1, 1.0]); + src.fill_rect(Rect::new(70, 60, 90, 130), &[0.1, 0.9, 0.4, 1.0]); + let area = Rect::new(0, 0, 160, 160); + for &filter in GalleryFilter::ALL { + let run = |e: GalleryEffect| apply(&src, &FilterParams::FilterGallery { effects: vec![e] }, area, area, None).read_region(area); + assert_eq!( + run(scaled_to_max(filter, 3.0)), + run(scaled_to_max(filter, 1.0)), + "{filter:?}: a stored value past its range must render as the clamped one" + ); + } +} From 1d86b32bab6a8b1d7fa979539aaa4a0feef641e4 Mon Sep 17 00:00:00 2001 From: Raghav Krishna Date: Tue, 3 Nov 2026 15:52:40 +0530 Subject: [PATCH 33/42] Crop: give each overlay its own glyph in the menus and on the options button Six original icons in the house style, one per crop overlay, drawn in the crop-mark frame around the guide they name. They sit between the check mark and the label in the options-bar overlay menu and in the canvas Crop menu, and the options-bar button paints the overlay in use. Tests: crates/ui-egui/tests/it/crop_overlay_icons.rs (#10889). --- ATTRIBUTION.md | 3 +- assets/icons/crop-overlay-diagonal.svg | 15 +++ assets/icons/crop-overlay-golden-ratio.svg | 15 +++ assets/icons/crop-overlay-golden-spiral.svg | 15 +++ assets/icons/crop-overlay-grid.svg | 15 +++ assets/icons/crop-overlay-thirds.svg | 15 +++ assets/icons/crop-overlay-triangle.svg | 15 +++ crates/ui-egui/src/canvas/canvas_tool_menu.rs | 8 +- crates/ui-egui/src/icon_data.rs | 7 ++ crates/ui-egui/src/tools/crop_overlay.rs | 39 ++++++-- crates/ui-egui/tests/it/crop_overlay_icons.rs | 92 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 12 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 assets/icons/crop-overlay-diagonal.svg create mode 100644 assets/icons/crop-overlay-golden-ratio.svg create mode 100644 assets/icons/crop-overlay-golden-spiral.svg create mode 100644 assets/icons/crop-overlay-grid.svg create mode 100644 assets/icons/crop-overlay-thirds.svg create mode 100644 assets/icons/crop-overlay-triangle.svg create mode 100644 crates/ui-egui/tests/it/crop_overlay_icons.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 700bccb19f..ffbf025415 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -34,10 +34,11 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/crop-overlay-diagonal.svg b/assets/icons/crop-overlay-diagonal.svg new file mode 100644 index 0000000000..429dfe5d5d --- /dev/null +++ b/assets/icons/crop-overlay-diagonal.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-ratio.svg b/assets/icons/crop-overlay-golden-ratio.svg new file mode 100644 index 0000000000..3ebe4c14b8 --- /dev/null +++ b/assets/icons/crop-overlay-golden-ratio.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-spiral.svg b/assets/icons/crop-overlay-golden-spiral.svg new file mode 100644 index 0000000000..4e365613b7 --- /dev/null +++ b/assets/icons/crop-overlay-golden-spiral.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-grid.svg b/assets/icons/crop-overlay-grid.svg new file mode 100644 index 0000000000..aebe804016 --- /dev/null +++ b/assets/icons/crop-overlay-grid.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-thirds.svg b/assets/icons/crop-overlay-thirds.svg new file mode 100644 index 0000000000..3893cfd386 --- /dev/null +++ b/assets/icons/crop-overlay-thirds.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-triangle.svg b/assets/icons/crop-overlay-triangle.svg new file mode 100644 index 0000000000..70c915d38c --- /dev/null +++ b/assets/icons/crop-overlay-triangle.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 9d5b84651f..5bfd687df7 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -1155,7 +1155,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { continue; }; let text = if crop_label_is_ui(&command) { tl!(&label).to_string() } else { label }; - let item = egui::Button::selectable(mode_checked(app, &menu, &command), text).min_size(egui::vec2(200.0, 20.0)); + // The overlay rows carry their own glyph (#10889); the rest stay text-only. + let mut atoms = egui::Atoms::default(); + if let Some(kind) = command.strip_prefix(CROP_OVERLAY).and_then(crate::crop_overlay::CropOverlay::from_id) { + atoms.push_right(crate::icons::image(kind.icon(), 14.0, t.icon)); + } + atoms.push_right(text); + let item = egui::Button::selectable(mode_checked(app, &menu, &command), atoms).min_size(egui::vec2(200.0, 20.0)); if ui.add_enabled(entry_enabled(app, &menu, &command), item).clicked() { selected = Some(command); } diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index d261e762cd..f4552128ce 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -50,6 +50,13 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("convert-point", include_bytes!("../../../assets/icons/convert-point.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), ("crop", include_bytes!("../../../assets/icons/crop.svg")), + // Original: the Crop tool's overlay glyphs (#10889), on the Lucide grid and stroke. + ("crop-overlay-diagonal", include_bytes!("../../../assets/icons/crop-overlay-diagonal.svg")), + ("crop-overlay-golden-ratio", include_bytes!("../../../assets/icons/crop-overlay-golden-ratio.svg")), + ("crop-overlay-golden-spiral", include_bytes!("../../../assets/icons/crop-overlay-golden-spiral.svg")), + ("crop-overlay-grid", include_bytes!("../../../assets/icons/crop-overlay-grid.svg")), + ("crop-overlay-thirds", include_bytes!("../../../assets/icons/crop-overlay-thirds.svg")), + ("crop-overlay-triangle", include_bytes!("../../../assets/icons/crop-overlay-triangle.svg")), ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index c93f5e12e1..85d41da407 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -55,6 +55,19 @@ impl CropOverlay { } } + /// The icon the overlay menus and the options-bar button show (#10889), drawn in the house + /// icon style with the frame's crop marks around the guide it names. + pub fn icon(self) -> &'static str { + match self { + Self::Thirds => "crop-overlay-thirds", + Self::Grid => "crop-overlay-grid", + Self::Diagonal => "crop-overlay-diagonal", + Self::Triangle => "crop-overlay-triangle", + Self::GoldenRatio => "crop-overlay-golden-ratio", + Self::GoldenSpiral => "crop-overlay-golden-spiral", + } + } + pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|k| k.id() == id) } @@ -283,32 +296,44 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// The options bar's overlay button and its menu (Photoshop's "Set the overlay options for the /// Crop tool"). pub fn options_button(o: &mut ToolOptions, ui: &mut egui::Ui) { - let r = - crate::icons::button(ui, "grid-3x3", 24.0, false, &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label()))); + let r = crate::icons::button( + ui, + o.crop_overlay.icon(), + 24.0, + false, + &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label())), + ); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(220.0); - let item = |ui: &mut egui::Ui, on: bool, label: &str, key: Option<&str>| { - let mut b = egui::Button::new(format!("{} {}", if on { "✔" } else { " " }, label)); + // Check mark, then the entry's icon (the overlays have one, #10889), then the label. + let item = |ui: &mut egui::Ui, on: bool, icon: Option<&str>, label: &str, key: Option<&str>| { + let mut atoms = egui::Atoms::default(); + atoms.push_right(format!("{} ", if on { "✔" } else { " " })); + if let Some(name) = icon { + atoms.push_right(crate::icons::image(name, 14.0, crate::theme::Tokens::get(ui.ctx()).icon)); + } + atoms.push_right(label.to_owned()); + let mut b = egui::Button::new(atoms); if let Some(k) = key { b = b.shortcut_text(crate::shortcuts::pretty(k)); } ui.add(b).clicked() }; for k in CropOverlay::ALL { - if item(ui, o.crop_overlay == k, tl!(k.label()), None) { + if item(ui, o.crop_overlay == k, Some(k.icon()), tl!(k.label()), None) { set_overlay(o, k); ui.close(); } } ui.separator(); for s in OverlayShow::ALL { - if item(ui, o.crop_overlay_show == s, tl!(s.label()), None) { + if item(ui, o.crop_overlay_show == s, None, tl!(s.label()), None) { o.crop_overlay_show = s; ui.close(); } } ui.separator(); - if item(ui, false, tl!("Cycle Overlay"), Some("O")) { + if item(ui, false, None, tl!("Cycle Overlay"), Some("O")) { cycle(o); ui.close(); } diff --git a/crates/ui-egui/tests/it/crop_overlay_icons.rs b/crates/ui-egui/tests/it/crop_overlay_icons.rs new file mode 100644 index 0000000000..d5b2859441 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -0,0 +1,92 @@ +//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints +//! the one of the overlay in use, and the overlay menu lists a glyph per entry. + +use std::collections::HashSet; + +use egui_kittest::Harness; +use photocraft_ui_egui::crop_overlay::CropOverlay; +use photocraft_ui_egui::icons; +use photocraft_ui_egui::state::ToolOptions; + +fn collect(shape: &egui::Shape, ids: &mut HashSet) { + match shape { + egui::Shape::Mesh(m) => { + ids.insert(m.texture_id); + } + // Images (the SVG glyphs) are rects filled with a texture in egui 0.37. + egui::Shape::Rect(r) => { + if let Some(brush) = r.brush.as_ref() + && brush.fill_texture_id != egui::TextureId::default() + { + ids.insert(brush.fill_texture_id); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, ids)), + _ => {} + } +} + +fn drawn_textures(h: &Harness<'_, ToolOptions>) -> HashSet { + let mut ids = HashSet::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut ids); + } + ids +} + +fn harness() -> Harness<'static, ToolOptions> { + let h = Harness::builder() + .with_size(egui::vec2(320.0, 520.0)) + .with_max_steps(64) + .with_accessibility_check(false) + .build_ui_state(|ui, o: &mut ToolOptions| photocraft_ui_egui::crop_overlay::options_button(o, ui), ToolOptions::default()); + // The SVG icon loader lives in the app's context setup; without it no glyph is rasterized. + photocraft_ui_egui::PhotocraftApp::setup_context(&h.ctx, Default::default()); + h +} + +#[test] +fn every_overlay_has_its_own_embedded_icon() { + let mut names: Vec<&str> = CropOverlay::ALL.iter().map(|k| k.icon()).collect(); + for name in &names { + assert!(icons::exists(name), "{name} is not embedded"); + } + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), CropOverlay::ALL.len(), "one icon per overlay"); +} + +#[test] +fn the_overlay_button_paints_the_current_overlays_icon() { + // One context: the SVG loader gives every new file its own texture, so the id changes as the + // overlay (and therefore the button's glyph) changes. + let mut h = Harness::builder() + .with_size(egui::vec2(60.0, 60.0)) + .with_max_steps(64) + .with_accessibility_check(false) + .build_ui_state(|ui, o: &mut ToolOptions| photocraft_ui_egui::crop_overlay::options_button(o, ui), ToolOptions::default()); + photocraft_ui_egui::PhotocraftApp::setup_context(&h.ctx, Default::default()); + let mut glyphs = Vec::new(); + for overlay in CropOverlay::ALL { + h.state_mut().crop_overlay = overlay; + h.run(); + let ids = drawn_textures(&h); + assert_eq!(ids.len(), 1, "{overlay:?}: one glyph on the button, got {ids:?}"); + glyphs.push(*ids.iter().next().expect("a glyph")); + } + let unique: HashSet<_> = glyphs.iter().copied().collect(); + assert_eq!(unique.len(), CropOverlay::ALL.len(), "the button follows the overlay: {glyphs:?}"); +} + +#[test] +fn the_overlay_menu_lists_a_glyph_per_entry() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut h = harness(); + h.run(); + h.get_by_role(Role::Button).click(); + h.run(); + let ids = drawn_textures(&h); + assert!(ids.len() >= CropOverlay::ALL.len(), "one glyph per overlay entry plus the button: {ids:?}"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..46a951a1dd 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_overlay_icons; mod crop_popup_keys; mod custom_print_paper; mod display_shadows; From 2b0af5618bc2b2480950fe7ecd41810c6b5643a7 Mon Sep 17 00:00:00 2001 From: Raghav Krishna Date: Tue, 3 Nov 2026 16:11:55 +0530 Subject: [PATCH 34/42] Test the canvas Crop menu glyphs Review follow-up: open the menu on a started crop and assert it adds a glyph texture per overlay, so the canvas menu's icon wiring cannot regress silently. --- crates/ui-egui/tests/it/crop_overlay_icons.rs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/crop_overlay_icons.rs b/crates/ui-egui/tests/it/crop_overlay_icons.rs index d5b2859441..8ebadcb52e 100644 --- a/crates/ui-egui/tests/it/crop_overlay_icons.rs +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -1,4 +1,4 @@ -//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints +//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints //! the one of the overlay in use, and the overlay menu lists a glyph per entry. use std::collections::HashSet; @@ -6,7 +6,7 @@ use std::collections::HashSet; use egui_kittest::Harness; use photocraft_ui_egui::crop_overlay::CropOverlay; use photocraft_ui_egui::icons; -use photocraft_ui_egui::state::ToolOptions; +use photocraft_ui_egui::state::{Tool, ToolOptions}; fn collect(shape: &egui::Shape, ids: &mut HashSet) { match shape { @@ -26,7 +26,7 @@ fn collect(shape: &egui::Shape, ids: &mut HashSet) { } } -fn drawn_textures(h: &Harness<'_, ToolOptions>) -> HashSet { +fn drawn_textures(h: &Harness<'_, State>) -> HashSet { let mut ids = HashSet::new(); for shape in &h.output().shapes { collect(&shape.shape, &mut ids); @@ -90,3 +90,26 @@ fn the_overlay_menu_lists_a_glyph_per_entry() { let ids = drawn_textures(&h); assert!(ids.len() >= CropOverlay::ALL.len(), "one glyph per overlay entry plus the button: {ids:?}"); } + +/// The canvas right-click Crop menu's overlay rows carry the same glyphs (#10889). +#[test] +fn the_canvas_crop_menu_lists_a_glyph_per_overlay() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).with_accessibility_check(false).build_eframe(|cc| { + photocraft_ui_egui::PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + app.run("file.new", serde_json::json!({"name": "crop menu", "width": 400, "height": 300})).expect("new document"); + app.ui.tool = Tool::Crop; + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + // The menu opens on a crop the user has started, so nudge the untouched frame once. + photocraft_ui_egui::crop_ui::nudge(&mut app, 1.0, 0.0); + app + }); + h.run(); + let before = drawn_textures(&h); + let ctx = h.ctx.clone(); + let opened = photocraft_ui_egui::canvas::canvas_tool_menu::open_crop(h.state_mut(), &ctx, Tool::Crop, [600.0, 400.0]); + assert!(opened, "the crop menu opens on a pending crop"); + h.run(); + let added: Vec<_> = drawn_textures(&h).difference(&before).copied().collect(); + assert!(added.len() >= CropOverlay::ALL.len(), "the menu adds a glyph per overlay: {added:?}"); +} From f72165ad42dd95f836b502f99d3ddc1ca52f4f7b Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:17:12 +0100 Subject: [PATCH 35/42] Layers panel: filter by layer effect (#10880) Effect comes after Name in the filter-type dropdown. A second dropdown picks Bevel & Emboss, Stroke, Inner Shadow, Inner Glow, Satin, Overlay, Outer Glow or Drop Shadow, and the panel lists the layers with that effect turned on. Overlay covers Color, Gradient and Pattern Overlay. ui.set {layerFilter} takes the effect by id. --- crates/automation/src/server.rs | 12 +- crates/ui-egui/src/panels/layer_filter_ui.rs | 40 ++++-- .../src/panels/layer_filter_ui/effect.rs | 95 +++++++++++++ .../panels/layer_filter_ui/effect_tests.rs | 128 ++++++++++++++++++ crates/ui-egui/src/state.rs | 4 + docs/control-protocol.md | 2 +- 6 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/effect.rs create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b3bf9f3f11..8efc66e10a 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -358,11 +358,13 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or - /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a - /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": - /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not - /// over this channel). updatesOpen (bool, the Help › Check for Updates window). + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect" + /// or "mode"), kinds (a list of layer kinds), name (matches part of the layer name), mode (a + /// blend mode name) and effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", + /// "overlay", "outerGlow" or "dropShadow") for the Layers panel filter). strokeTrace (true, + /// false, "stderr" or "off": the stroke latency trace at runtime; a CSV file is set at launch + /// with --stroke-trace, not over this channel). updatesOpen (bool, the Help › Check for Updates + /// window). /// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel /// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b", /// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage", diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..cf2af09588 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,6 +1,6 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the -//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode -//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel +//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Effect +//! picks one effect kind (#10880), Mode picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel //! can read and set them. use photocraft_color::BlendMode; @@ -17,6 +17,7 @@ pub enum FilterBy { #[default] Kind, Name, + Effect, Mode, } @@ -28,20 +29,28 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub effect: effect::EffectKind, } impl Filter { pub fn of(ui: &UiState) -> Self { - Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + Self { + by: ui.layer_filter_by, + kinds: ui.layer_filter.clone(), + name: ui.layer_filter_name.clone(), + mode: ui.layer_filter_mode, + effect: ui.layer_filter_effect, + } } - /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); + /// Whether the panel is filtered. Like Photoshop, Mode and Effect always filter (by Normal and + /// by Bevel & Emboss at first); /// Kind and Name only once a kind is toggled or some text is typed. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode => true, + FilterBy::Effect | FilterBy::Mode => true, } } @@ -53,6 +62,7 @@ impl Filter { match self.by { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), + FilterBy::Effect => self.effect.shows(l), FilterBy::Mode => l.blend == self.mode, } } @@ -66,13 +76,12 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; /// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; - if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode", "effect"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode, effect)")); } - let mut f = - Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, effect or mode".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -95,6 +104,9 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result &'static str { pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Effect, tl!("Effect")), (FilterBy::Mode, tl!("Mode"))]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { @@ -148,6 +161,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let width = ui.available_width().max(48.0); widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); } + FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); let width = ui.available_width().max(96.0); @@ -157,5 +171,9 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } +pub mod effect; + +#[cfg(test)] +mod effect_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs new file mode 100644 index 0000000000..1ce24754ae --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs @@ -0,0 +1,95 @@ +//! The Layers panel's Effect filter (#10880): lists the layers with an enabled effect of one kind. +//! Overlay covers Color, Gradient and Pattern Overlay, as in Photoshop. + +use photocraft_doc::{Effect, Layer}; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The effect kinds the Effect filter picks from, in Photoshop's order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EffectKind { + #[default] + BevelEmboss, + Stroke, + InnerShadow, + InnerGlow, + Satin, + Overlay, + OuterGlow, + DropShadow, +} + +impl EffectKind { + pub const ALL: [EffectKind; 8] = [ + EffectKind::BevelEmboss, + EffectKind::Stroke, + EffectKind::InnerShadow, + EffectKind::InnerGlow, + EffectKind::Satin, + EffectKind::Overlay, + EffectKind::OuterGlow, + EffectKind::DropShadow, + ]; + + /// The id `ui.set {layerFilter: {effect}}` takes. + pub fn id(self) -> &'static str { + match self { + EffectKind::BevelEmboss => "bevelEmboss", + EffectKind::Stroke => "stroke", + EffectKind::InnerShadow => "innerShadow", + EffectKind::InnerGlow => "innerGlow", + EffectKind::Satin => "satin", + EffectKind::Overlay => "overlay", + EffectKind::OuterGlow => "outerGlow", + EffectKind::DropShadow => "dropShadow", + } + } + + fn label(self) -> &'static str { + match self { + EffectKind::BevelEmboss => tl!("Bevel & Emboss"), + EffectKind::Stroke => tl!("Stroke"), + EffectKind::InnerShadow => tl!("Inner Shadow"), + EffectKind::InnerGlow => tl!("Inner Glow"), + EffectKind::Satin => tl!("Satin"), + EffectKind::Overlay => tl!("Overlay"), + EffectKind::OuterGlow => tl!("Outer Glow"), + EffectKind::DropShadow => tl!("Drop Shadow"), + } + } + + fn matches(self, e: &Effect) -> bool { + matches!( + (self, e), + (EffectKind::BevelEmboss, Effect::BevelEmboss(_)) + | (EffectKind::Stroke, Effect::Stroke(_)) + | (EffectKind::InnerShadow, Effect::InnerShadow(_)) + | (EffectKind::InnerGlow, Effect::InnerGlow(_)) + | (EffectKind::Satin, Effect::Satin(_)) + | (EffectKind::Overlay, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. }) + | (EffectKind::OuterGlow, Effect::OuterGlow(_)) + | (EffectKind::DropShadow, Effect::DropShadow(_)) + ) + } + + /// Whether `l` has an enabled effect of this kind; a turned-off one doesn't count. + pub fn shows(self, l: &Layer) -> bool { + l.effects.items.iter().any(|e| e.enabled() && self.matches(e)) + } +} + +/// `layerFilter.effect`: the kind named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || EffectKind::ALL.map(EffectKind::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.effect must be one of {}", ids()))?; + EffectKind::ALL.into_iter().find(|k| k.id() == name).ok_or_else(|| format!("layerFilter.effect: unknown effect `{name}` (effects: {})", ids())) +} + +/// The Effect filter's controls: the effect kind dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let kinds: Vec<(EffectKind, &str)> = EffectKind::ALL.into_iter().map(|k| (k, k.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-effect", tl!("Effect"), &mut app.ui.layer_filter_effect, &kinds, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs new file mode 100644 index 0000000000..326de21e4e --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs @@ -0,0 +1,128 @@ +//! #10880: the Layers panel's Effect filter, against the cases measured in Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::Document; +use serde_json::json; + +use super::effect::EffectKind; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +/// One pixel layer per effect kind (the three overlays each on their own), "shadow off" whose +/// Drop Shadow is turned off, and "plain" without effects, over the Background. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for (name, kind, params) in [ + ("plain", "", json!({})), + ("bevel", "bevelEmboss", json!({})), + ("stroke", "stroke", json!({})), + ("inner shadow", "innerShadow", json!({})), + ("inner glow", "innerGlow", json!({})), + ("satin", "satin", json!({})), + ("color", "colorOverlay", json!({"color": "#ff8000"})), + ("gradient", "gradientOverlay", json!({})), + ("pattern", "patternOverlay", json!({})), + ("outer glow", "outerGlow", json!({})), + ("shadow", "dropShadow", json!({})), + ("shadow off", "dropShadow", json!({"enabled": false})), + ] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + if !kind.is_empty() { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]})).unwrap(); + } + } + s +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn by_effect(effect: EffectKind) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Effect, layer_filter_effect: effect, ..UiState::default() }) +} + +#[test] +fn each_effect_lists_the_layers_with_it_enabled() { + let s = session(); + let doc = &s.active().unwrap().doc; + for (effect, want) in [ + (EffectKind::BevelEmboss, &["bevel"][..]), + (EffectKind::Stroke, &["stroke"]), + (EffectKind::InnerShadow, &["inner shadow"]), + (EffectKind::InnerGlow, &["inner glow"]), + (EffectKind::Satin, &["satin"]), + // Overlay covers Color, Gradient and Pattern Overlay. + (EffectKind::Overlay, &["pattern", "gradient", "color"]), + (EffectKind::OuterGlow, &["outer glow"]), + // A turned-off Drop Shadow doesn't count. + (EffectKind::DropShadow, &["shadow"]), + ] { + assert_eq!(listed(doc, &by_effect(effect)), want, "{effect:?}"); + } +} + +#[test] +fn effect_filters_at_once_with_bevel_and_emboss() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Effect, ..UiState::default() }); + assert_eq!(f.effect, EffectKind::BevelEmboss); + assert!(f.active(), "Effect filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_effect_by_id() { + let mut ui = UiState::default(); + for effect in EffectKind::ALL { + let f = super::patched(&ui, &json!({"by": "effect", "effect": effect.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_effect), (FilterBy::Effect, effect)); + } + let err = super::patched(&ui, &json!({"effect": "colorOverlay"})).unwrap_err(); + assert!(err.contains("colorOverlay") && err.contains("bevelEmboss") && err.contains("dropShadow"), "{err}"); + assert!(super::patched(&ui, &json!({"effect": 3})).is_err()); + assert!(super::patched(&ui, &json!({"effect": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_effect_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_effect: EffectKind::Satin, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_effect"], "satin"); + saved.as_object_mut().unwrap().remove("layer_filter_effect"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_effect, EffectKind::BevelEmboss); +} + +/// The names of the rows the Layers panel drew, top to bottom. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> Vec { + let doc = &h.state().session.active().unwrap().doc; + let mut rows = crate::layer_row_ui::recorded(&h.ctx); + rows.sort_by(|a, b| a.row.top().total_cmp(&b.row.top())); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_effect_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 1200.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Effect").last().expect("Effect in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Effect); + assert_eq!(drawn(&h), ["bevel"], "Bevel & Emboss at first"); + h.get_by_value("Bevel & Emboss").click(); + h.run_steps(4); + h.query_all_by_label("Drop Shadow").last().expect("Drop Shadow in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_effect, EffectKind::DropShadow); + assert_eq!(drawn(&h), ["shadow"]); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c18a418c4c..02b56d36bd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -364,6 +364,9 @@ pub struct UiState { /// Layers panel blend-mode filter. #[serde(default = "default_layer_filter_mode")] pub layer_filter_mode: photocraft_color::BlendMode, + /// Layers panel effect filter (#10880). + #[serde(default)] + pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -575,6 +578,7 @@ impl Default for UiState { layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), + layer_filter_effect: Default::default(), pen: None, pen_doc: None, stroke_editor: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e74938dc2f..d623e7ae17 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root -- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?, effect?}`, any subset. `by` is `kind`, `name`, `effect` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`); `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on. Kind and Name filter only once a kind is on or text is typed; Mode and Effect always filter, by Normal and by Bevel & Emboss at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - 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. From 286a152fee41989eba6e055c885196787e536ab4 Mon Sep 17 00:00:00 2001 From: Raghav Krishna Date: Tue, 3 Nov 2026 20:47:07 +0530 Subject: [PATCH 36/42] Test that each canvas Crop menu row draws its own glyph --- crates/ui-egui/tests/it/crop_overlay_icons.rs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/it/crop_overlay_icons.rs b/crates/ui-egui/tests/it/crop_overlay_icons.rs index 8ebadcb52e..6c413b262b 100644 --- a/crates/ui-egui/tests/it/crop_overlay_icons.rs +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -1,4 +1,4 @@ -//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints +//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints //! the one of the overlay in use, and the overlay menu lists a glyph per entry. use std::collections::HashSet; @@ -34,6 +34,21 @@ fn drawn_textures(h: &Harness<'_, State>) -> HashSet { ids } +/// Every glyph texture and the rectangle it covers, in paint order. +fn glyph_rects(shape: &egui::Shape, out: &mut Vec<(egui::TextureId, egui::Rect)>) { + match shape { + egui::Shape::Rect(r) => { + if let Some(brush) = r.brush.as_ref() + && brush.fill_texture_id != egui::TextureId::default() + { + out.push((brush.fill_texture_id, r.rect)); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| glyph_rects(shape, out)), + _ => {} + } +} + fn harness() -> Harness<'static, ToolOptions> { let h = Harness::builder() .with_size(egui::vec2(320.0, 520.0)) @@ -91,7 +106,24 @@ fn the_overlay_menu_lists_a_glyph_per_entry() { assert!(ids.len() >= CropOverlay::ALL.len(), "one glyph per overlay entry plus the button: {ids:?}"); } -/// The canvas right-click Crop menu's overlay rows carry the same glyphs (#10889). +/// The glyph texture its row drew, found by row bounds. +fn row_glyph(h: &Harness<'_, State>, label: &str) -> egui::TextureId { + use egui_kittest::kittest::Queryable; + + let mut drawn = Vec::new(); + for shape in &h.output().shapes { + glyph_rects(&shape.shape, &mut drawn); + } + h.get_all_by_label(label) + .map(|node| node.rect()) + .find_map(|row| drawn.iter().find(|(_, rect)| row.contains_rect(*rect)).map(|(id, _)| *id)) + .unwrap_or_else(|| panic!("no {label} row draws a glyph")) +} + +/// The canvas right-click Crop menu's overlay rows carry the same glyphs (#10889), each row its +/// own: a swapped icon still adds six textures, so the count alone proves nothing. Checked per +/// row, and pairwise distinct. (Comparing against a freshly loaded 14 px glyph does not work: +/// egui frees icon textures at the end of every pass, so the reload gets a new id.) #[test] fn the_canvas_crop_menu_lists_a_glyph_per_overlay() { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).with_accessibility_check(false).build_eframe(|cc| { @@ -105,11 +137,11 @@ fn the_canvas_crop_menu_lists_a_glyph_per_overlay() { app }); h.run(); - let before = drawn_textures(&h); let ctx = h.ctx.clone(); let opened = photocraft_ui_egui::canvas::canvas_tool_menu::open_crop(h.state_mut(), &ctx, Tool::Crop, [600.0, 400.0]); assert!(opened, "the crop menu opens on a pending crop"); h.run(); - let added: Vec<_> = drawn_textures(&h).difference(&before).copied().collect(); - assert!(added.len() >= CropOverlay::ALL.len(), "the menu adds a glyph per overlay: {added:?}"); + let glyphs: Vec<_> = CropOverlay::ALL.iter().map(|kind| row_glyph(&h, kind.label())).collect(); + let unique: HashSet<_> = glyphs.iter().copied().collect(); + assert_eq!(unique.len(), CropOverlay::ALL.len(), "one glyph per row: {glyphs:?}"); } From 1171d6cce4b48c850994a8bd1caa5d727dcdce98 Mon Sep 17 00:00:00 2001 From: Raghav Krishna Date: Tue, 3 Nov 2026 21:02:36 +0530 Subject: [PATCH 37/42] Test that the six overlay drawings are all different --- crates/ui-egui/tests/it/crop_overlay_icons.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/tests/it/crop_overlay_icons.rs b/crates/ui-egui/tests/it/crop_overlay_icons.rs index 6c413b262b..2cbc8195e6 100644 --- a/crates/ui-egui/tests/it/crop_overlay_icons.rs +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -71,6 +71,25 @@ fn every_overlay_has_its_own_embedded_icon() { assert_eq!(names.len(), CropOverlay::ALL.len(), "one icon per overlay"); } +/// The six files are different drawings, not one picture saved under six names: a duplicate would +/// still get six texture ids and pass the texture checks above. +#[test] +fn the_overlay_glyphs_are_six_different_drawings() { + let art: [(&str, &[u8]); 6] = [ + ("crop-overlay-thirds", include_bytes!("../../../../assets/icons/crop-overlay-thirds.svg")), + ("crop-overlay-grid", include_bytes!("../../../../assets/icons/crop-overlay-grid.svg")), + ("crop-overlay-diagonal", include_bytes!("../../../../assets/icons/crop-overlay-diagonal.svg")), + ("crop-overlay-triangle", include_bytes!("../../../../assets/icons/crop-overlay-triangle.svg")), + ("crop-overlay-golden-ratio", include_bytes!("../../../../assets/icons/crop-overlay-golden-ratio.svg")), + ("crop-overlay-golden-spiral", include_bytes!("../../../../assets/icons/crop-overlay-golden-spiral.svg")), + ]; + let icons: Vec<&str> = CropOverlay::ALL.iter().map(|k| k.icon()).collect(); + assert_eq!(art.iter().map(|(name, _)| *name).collect::>(), icons, "one file per overlay, in ALL order"); + for (i, (name, bytes)) in art.iter().enumerate() { + assert!(art[..i].iter().all(|(_, other)| other != bytes), "{name} repeats another overlay's drawing"); + } +} + #[test] fn the_overlay_button_paints_the_current_overlays_icon() { // One context: the SVG loader gives every new file its own texture, so the id changes as the From 3c82477fac87c835b3585572cac0d9bf1fd92945 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:57:16 +0100 Subject: [PATCH 38/42] Crop: turning the image keeps the frame inside it (#10869) In the default mode a drag outside the box turns the image, but the frame never changed, so its corners left the turned image and the crop filled them with the background colour. Each move of the turn now starts from the frame at the press: it keeps its size and place if it fits the turned image, else moves the shortest way in, else shrinks about its centre (keeping its proportions) to the largest size that fits. The view follows the frame's centre, so the box stays put on screen. Ctrl (Cmd) held during the turn leaves the frame as it was. Content-Aware Fill and a frame already past the canvas keep today's behaviour (not measured). --- crates/ui-egui/src/tools/crop_mode.rs | 71 +++- crates/ui-egui/src/tools/crop_ui.rs | 10 +- .../tests/it/crop_turn_keeps_frame_inside.rs | 308 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs diff --git a/crates/ui-egui/src/tools/crop_mode.rs b/crates/ui-egui/src/tools/crop_mode.rs index cc946b30b9..14eb64c8d3 100644 --- a/crates/ui-egui/src/tools/crop_mode.rs +++ b/crates/ui-egui/src/tools/crop_mode.rs @@ -3,7 +3,8 @@ //! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on //! screen and the image turns behind it. A drag outside the box turns the image about the box's //! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise -//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap +//! positive) and keeps the frame inside the turned image ([`keep_inside`]; not with ⌘ held), a +//! drag inside moves the image under the box (the box stays put; with View › Snap //! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow //! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center //! Preview** on (the default), a box being resized keeps its centre where it is on screen (also @@ -114,6 +115,74 @@ pub fn image_turn(frame_angle: f64) -> f64 { if a == 0.0 { 0.0 } else { a } } +/// How far (document px) a turned frame's corner may lie past the canvas and still count as on +/// it: rounding, not a visible gap. +const FIT_TOLERANCE: f64 = 1e-6; + +/// Frame `rect` turned `deg` degrees about its centre, kept inside a `cw`×`ch` canvas (#10869): +/// `rect` itself when it fits; else moved the shortest way in; else shrunk about its centre, +/// keeping its proportions, to the largest size that fits anywhere, then moved in. `None` when a +/// size or the angle isn't finite, or a size isn't positive. +pub fn kept_inside(rect: [f64; 4], deg: f64, cw: f64, ch: f64) -> Option<[f64; 4]> { + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + if ![w, h, cw, ch, deg].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 || cw <= 0.0 || ch <= 0.0 { + return None; + } + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + // Half the turned frame's bounds: its corners are on the canvas when these are. + let (ex, ey) = ((w * c + h * s) / 2.0, (w * s + h * c) / 2.0); + let m = crate::crop_ui::center(rect); + // A frame that fits stays bit for bit, also one whose corners a rounding error puts a hair + // past the edges (a frame fitted by an earlier turn touches them). + let fits = |v: f64, e: f64, len: f64| v - e >= -FIT_TOLERANCE && v + e <= len + FIT_TOLERANCE; + if fits(m[0], ex, cw) && fits(m[1], ey, ch) { + return Some(rect); + } + // The scale that makes the bounds fit the canvas; a rounding error short of 1 (180°) fits. + let k = (cw / (2.0 * ex)).min(ch / (2.0 * ey)); + let k = if k >= 1.0 - 1e-9 { 1.0 } else { k }; + // Each axis on its own: the nearest centre that keeps the bounds on the canvas. + let along = |v: f64, e: f64, len: f64| if 2.0 * e >= len { len / 2.0 } else { v.clamp(e, len - e) }; + let n = [along(m[0], ex * k, cw), along(m[1], ey * k, ch)]; + let r = if k == 1.0 { + // Kept size: moved by exactly the offset. + let d = [n[0] - m[0], n[1] - m[1]]; + [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]] + } else { + let (hw, hh) = (w * k / 2.0, h * k / 2.0); + [n[0] - hw, n[1] - hh, n[0] + hw, n[1] + hh] + }; + r.iter().all(|v| v.is_finite()).then_some(r) +} + +/// A default-mode turn keeps the frame inside the turned image, as in Photoshop (#10869): the +/// frame the drag started from, `rect` (turned `from` degrees then), keeps its size and place +/// when it fits the image at the frame's angle now, else [`kept_inside`] moves it in or shrinks +/// it. Each step starts from the press frame, so turning back gives the frame back. The view +/// moves with the frame's centre, so the box stays put on screen. With ⌘ (Ctrl) `held` the frame +/// stays as it was at the press and ↵ fills the uncovered corners. +/// +/// Not measured in Photoshop, so they keep the frame as before: Fill set to Content-Aware Fill, +/// and a frame that already reached past the canvas at the press. +pub fn keep_inside(app: &mut PhotocraftApp, rect: [f64; 4], from: f64, held: bool) { + let Some(now) = finite_frame(app) else { return }; + let Some((cw, ch)) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; + let inside_at_press = kept_inside(rect, from, cw, ch) == Some(rect); + let want = if held || app.ui.tool_options.crop_content_aware || !inside_at_press { + rect + } else { + kept_inside(rect, crate::crop_ui::angle(app), cw, ch).unwrap_or(rect) + }; + if want == now { + return; + } + // Moves the frame's centre and the view together (the box stays put), then sets the size. + let (a, b) = (crate::crop_ui::center(now), crate::crop_ui::center(want)); + shift_image(app, [a[0] - b[0], a[1] - b[1]]); + app.ui.crop_rect = Some(want); +} + /// A live-centred resize ([`live_resize`]): the frame `rect` and handle (`hx`, `hy`) at the press, /// grabbed at document point `start`; the pointer now at `q` (in the press view); the frame's turn /// `deg`, the aspect `ratio` to hold and ⌥ (resize about the centre). diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..7f738edae2 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -94,8 +94,9 @@ pub enum CropDrag { MoveImage { start: [f64; 2], rect: [f64; 4], view: Option }, /// Default mode, outside the box: the image turns about the frame's `center` (which stays put /// on screen). `grab` is the pointer's direction from it at the press (radians, in the press - /// `view`), `from` the frame's angle then (degrees). - TurnImage { center: [f64; 2], grab: f64, from: f64, view: Option }, + /// `view`), `from` the frame's angle then (degrees) and `rect` the frame then, which the turn + /// keeps inside the turned image (`crop_mode::keep_inside`). + TurnImage { center: [f64; 2], grab: f64, from: f64, rect: [f64; 4], view: Option }, } /// What the pointer is over, relative to a crop frame. @@ -851,7 +852,7 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C let c = center(rect); let grab = (p[1] - c[1]).atan2(p[0] - c[0]); if crate::crop_mode::moves_image(app) { - CropDrag::TurnImage { center: c, grab, from: deg, view: crate::crop_mode::press_view(app) } + CropDrag::TurnImage { center: c, grab, from: deg, rect, view: crate::crop_mode::press_view(app) } } else { // Outside the frame a drag turns it, as in Photoshop (#1792). CropDrag::Rotate { center: c, grab, from: deg } @@ -919,13 +920,14 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bool { match app.crop.drag { Some(CropDrag::Rotate { center, grab, from }) => app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift), - Some(CropDrag::TurnImage { center, grab, from, view }) => { + Some(CropDrag::TurnImage { center, grab, from, rect, view }) => { // The image turns with the pointer's sweep on screen, so the frame turns against it. let at = crate::crop_mode::to_press(app, view, p); let turned = from - ((at[1] - center[1]).atan2(at[0] - center[0]) - grab).to_degrees(); if turned.is_finite() { app.ui.crop_angle = normalized_angle(if mods.shift { (turned / 15.0).round() * 15.0 } else { turned }); crate::crop_mode::sync_frame(app); + crate::crop_mode::keep_inside(app, rect, from, mods.command); } } Some(CropDrag::MoveImage { start, rect, view }) => { diff --git a/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs new file mode 100644 index 0000000000..74e7fbbef9 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs @@ -0,0 +1,308 @@ +//! Crop tool, default mode: turning the image keeps the frame inside the turned image (#10869). +//! +//! Measured in the reference application on an 800 × 500 document, turning by a drag outside the +//! frame with Auto Center Preview off: the frame keeps its size where it fits the turned image, +//! else moves the shortest way into it, else shrinks about its centre keeping its proportions. +//! With Ctrl (⌘) held the frame isn't adjusted and the uncovered corners get the Fill. +//! PhotoCraft never adjusted the frame, so ↵ filled the corners with the background colour. +//! +//! Driven as an agent does, through `ui.pointer` (document px as the view showed them at the +//! press). + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_mode::kept_inside; +use photocraft_ui_egui::crop_ui::{angle, center, corners}; +use serde_json::{Value, json}; + +/// The document's size. +const W: f64 = 800.0; +const H: f64 = 500.0; +/// The whole canvas, the default frame. +const CANVAS: [f64; 4] = [0.0, 0.0, W, H]; + +/// An 800 × 500 white document with a #1050e0 background colour, the Crop tool in the default +/// mode with Auto Center Preview off, and `frame` as the pending crop frame: the default frame +/// when it is the whole canvas, else drawn from its bottom-right corner to its top-left one (a +/// press on the default frame's corner handle would resize it). +fn app(frame: [f64; 4]) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": W, "height": H, "background": "white"})).expect("new"); + app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool_options.crop_shield.auto_center_preview = false; + control(&mut app, &ctx, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [W / 2.0, H / 2.0]})); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert!(!app.ui.tool_options.crop_shield.classic_mode, "Photoshop's default mode"); + if frame != CANVAS { + let events = json!([ + {"kind": "down", "x": frame[2], "y": frame[3]}, + {"kind": "move", "x": frame[0], "y": frame[1]}, + {"kind": "up", "x": frame[0], "y": frame[1]}, + ]); + control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + } + assert_eq!(app.ui.crop_rect, Some(frame)); + (app, ctx) +} + +/// Runs one control-channel request, which must succeed. +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); +} + +/// The point 500 px right of the frame's centre turned `deg` degrees clockwise about it: a press +/// there is outside every frame below, and moving to it from the unturned point turns the image +/// `deg` degrees clockwise. +fn around(frame: [f64; 4], deg: f64) -> [f64; 2] { + let c = center(frame); + let (s, k) = deg.to_radians().sin_cos(); + [c[0] + 500.0 * k, c[1] + 500.0 * s] +} + +/// A turn of the image: a press outside `frame`, then for each `(deg, modifiers)` step a move to +/// `deg` with those modifiers held (one `ui.pointer` call each, so a key can be pressed during +/// the drag); `each` runs after every step. Releases at the last step's point with its modifiers. +fn turn(app: &mut PhotocraftApp, ctx: &egui::Context, frame: [f64; 4], steps: &[(f64, Value)], mut each: impl FnMut(&PhotocraftApp, f64)) { + let p = around(frame, 0.0); + control(app, ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": p[0], "y": p[1]}]})); + for (deg, mods) in steps { + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "move", "x": p[0], "y": p[1]}]})); + each(app, *deg); + } + let (deg, mods) = steps.last().expect("a step"); + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "up", "x": p[0], "y": p[1]}]})); +} + +/// No modifier held. +fn plain() -> Value { + json!({}) +} + +/// The pending frame. +fn frame(app: &PhotocraftApp) -> [f64; 4] { + app.ui.crop_rect.expect("a frame") +} + +/// The pending frame's corners, turned with it, all lie on the canvas. +fn inside(app: &PhotocraftApp) -> bool { + corners(frame(app), angle(app)).iter().all(|c| (-1e-6..=W + 1e-6).contains(&c[0]) && (-1e-6..=H + 1e-6).contains(&c[1])) +} + +/// Where the frame's centre shows on screen. +fn on_screen(app: &PhotocraftApp) -> egui::Pos2 { + let c = center(frame(app)); + ViewXform::active(app).expect("a view").to_screen(c[0] as f32, c[1] as f32) +} + +/// ↵: commits the crop and returns the document's new size. +fn committed(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("document").doc.size; + (size.width, size.height) +} + +/// The flattened document's four corner pixels and its middle one. +fn corners_and_middle(app: &PhotocraftApp) -> [[f32; 4]; 5] { + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let px = |x: usize, y: usize| flat.px[y * w + x]; + [px(0, 0), px(w - 1, 0), px(w - 1, h - 1), px(0, h - 1), px(w / 2, h / 2)] +} + +/// The background colour #1050e0 rather than the white image: its red is 16/255. A corner that +/// the turned image covers only in part still has far more red than that. +fn background(p: [f32; 4]) -> bool { + p[0] < 0.5 && (p[2] - 224.0 / 255.0).abs() < 0.02 +} + +/// White: the image. +fn white(p: [f32; 4]) -> bool { + p[..3].iter().all(|&v| v > 0.98) +} + +/// The frame's angle is `want` degrees, up to the `f32` rounding of the view the pointer is read +/// through. +fn near_angle(deg: f64, want: f64) -> bool { + (deg - want).abs() < 1e-4 +} + +/// The size of the largest frame of the canvas's proportions that fits inside the canvas turned +/// `deg` degrees. +fn fitted_size(deg: f64) -> (f64, f64) { + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + let k = (W / (W * c + H * s)).min(H / (W * s + H * c)); + (W * k, H * k) +} + +/// The issue's first two rows: the whole-canvas frame shrinks about its centre, keeping its +/// proportions, to the largest size that fits (687 × 429 measured at 6.3°, 580 × 362 at the 15° +/// that ⇧ snaps to), live while dragging, with the box put on screen. ↵ crops to it and no corner +/// shows the background colour. +#[test] +fn turning_the_whole_canvas_frame_shrinks_it_inside_the_turned_image() { + for (deg, shift, measured) in [(6.3, false, (687.0, 429.0)), (13.0, true, (580.0, 362.0))] { + let what = format!("turned {deg}°, shift {shift}"); + let (mut app, ctx) = app(CANVAS); + let screen = on_screen(&app); + // ⇧ pressed after the press, as measured: the turn snaps to 15°. + let steps = [(deg / 2.0, plain()), (deg, json!({"shift": shift}))]; + turn(&mut app, &ctx, CANVAS, &steps, |app, step| { + assert!(inside(app), "{what}: at {step}° the frame {:?} turned {} leaves the image", frame(app), angle(app)); + assert!(on_screen(app).distance(screen) < 0.01, "{what}: at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let turned = if shift { 15.0 } else { deg }; + assert!(near_angle(angle(&app), -turned), "{what}: the frame's angle is {}", angle(&app)); + let (w, h) = fitted_size(angle(&app)); + let f = frame(&app); + let (fw, fh) = (f[2] - f[0], f[3] - f[1]); + assert!((fw - w).abs() < 1e-6 && (fh - h).abs() < 1e-6, "{what}: {fw} × {fh}, want {w} × {h}"); + assert!((fw - measured.0).abs() <= 3.0 && (fh - measured.1).abs() <= 3.0, "{what}: {fw} × {fh} is far from the reference's {measured:?}"); + let c = center(f); + assert!((c[0] - W / 2.0).abs() < 1e-6 && (c[1] - H / 2.0).abs() < 1e-6, "{what}: centred, not at {c:?}"); + let (cw, ch) = committed(&mut app); + assert!((f64::from(cw) - w).abs() <= 1.0 && (f64::from(ch) - h).abs() <= 1.0, "{what}: committed {cw} × {ch}"); + for p in corners_and_middle(&app) { + assert!(!background(p), "{what}: a corner shows the background colour: {p:?}"); + } + assert!(white(corners_and_middle(&app)[4]), "{what}: the middle is the image"); + } +} + +/// The issue's Ctrl row: with Ctrl (⌘) held the frame keeps the whole canvas and ↵ fills the +/// corners the turn uncovered. Pressed part way through the turn, Ctrl gives the press frame back +/// at once. +#[test] +fn with_command_held_the_turn_keeps_the_frame() { + for late in [false, true] { + let (mut app, ctx) = app(CANVAS); + let first = if late { plain() } else { json!({"command": true}) }; + turn(&mut app, &ctx, CANVAS, &[(3.0, first), (6.0, json!({"command": true}))], |app, step| { + if late && step == 3.0 { + assert!(frame(app) != CANVAS && inside(app), "Ctrl not held yet: the frame fits, {:?}", frame(app)); + } else { + assert_eq!(frame(app), CANVAS, "late {late}: at {step}°"); + } + }); + assert!(near_angle(angle(&app), -6.0), "late {late}: {}", angle(&app)); + assert_eq!(committed(&mut app), (800, 500), "late {late}"); + let [corner @ .., middle] = corners_and_middle(&app); + for p in corner { + assert!(background(p), "late {late}: an uncovered corner is {p:?}"); + } + assert!(white(middle), "late {late}: the middle is the image"); + } +} + +/// The issue's fourth row: a 300 × 201 frame at (100, 99) still lies inside the image turned +/// 11.3° about its centre, so it stays exactly as it was. +#[test] +fn a_frame_that_fits_the_turned_image_keeps_its_size_and_place() { + let start = [100.0, 99.0, 400.0, 300.0]; + let (mut app, ctx) = app(start); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.3, plain())], |app, step| { + assert_eq!(frame(app), start, "at {step}°"); + }); + assert!(near_angle(angle(&app), -11.3), "{}", angle(&app)); + assert_eq!(committed(&mut app), (300, 201)); + for p in corners_and_middle(&app) { + assert!(white(p), "{p:?}"); + } +} + +/// The issue's last row: a 300 × 200 frame in the top-left corner, turned 11.1°, keeps its size +/// and moves the shortest way into the turned image: its turned corners end up touching the +/// canvas's left and top edges, not further in. The box stays put on screen while the image +/// slides under it. +#[test] +fn a_frame_in_the_corner_moves_in_without_shrinking() { + let start = [0.0, 0.0, 300.0, 200.0]; + let (mut app, ctx) = app(start); + let screen = on_screen(&app); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.1, plain())], |app, step| { + assert!(inside(app), "at {step}° the frame {:?} leaves the image", frame(app)); + assert!(on_screen(app).distance(screen) < 0.01, "at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let f = frame(&app); + assert!(((f[2] - f[0]) - 300.0).abs() < 1e-9 && ((f[3] - f[1]) - 200.0).abs() < 1e-9, "the size is kept: {f:?}"); + let turned = corners(f, angle(&app)); + let left = turned.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min); + let top = turned.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min); + assert!(left.abs() < 1e-9 && top.abs() < 1e-9, "moved the shortest way: the turned frame's left {left}, top {top}"); + assert_eq!(committed(&mut app), (300, 200)); + for p in corners_and_middle(&app) { + assert!(!background(p), "a corner shows the background colour: {p:?}"); + } +} + +/// Every step of the turn starts from the frame at the press, so turning back within the same +/// drag gives the whole frame back instead of keeping the smallest size it passed through. +#[test] +fn turning_back_gives_the_frame_back() { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(10.0, plain()), (0.0, plain())], |_, _| {}); + assert_eq!((frame(&app), angle(&app)), (CANVAS, 0.0)); +} + +/// A second turn starts from the frame the first one fitted, whose turned corners touch the +/// canvas's edges: turned further it shrinks again, turned back upright it stays as it is. +#[test] +fn a_second_turn_starts_from_the_fitted_frame() { + for second in [10.0, -6.3] { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(6.3, plain())], |_, _| {}); + let first = frame(&app); + turn(&mut app, &ctx, first, &[(second, plain())], |_, _| {}); + let f = frame(&app); + let what = format!("then {second}°: {f:?} turned {}", angle(&app)); + assert!(near_angle(angle(&app), -(6.3 + second)), "{what}"); + assert!(inside(&app), "{what}"); + if second > 0.0 { + let want = fitted_size(angle(&app)); + assert!(((f[2] - f[0]) - want.0).abs() < 1e-6 && ((f[3] - f[1]) - want.1).abs() < 1e-6, "{what}: want {want:?}"); + } else { + assert_eq!(f, first, "{what}"); + } + } +} + +/// A frame `kept_inside` fitted is left exactly as it is at the same angle, at every tenth of a +/// degree up to 90°. The fitted frame's turned corners touch the canvas's edges, and at many +/// angles rounding puts them a hair past: without the fit's tolerance such a frame would count as +/// reaching past the canvas, and a second turn would no longer keep it inside. +#[test] +fn a_fitted_frame_fits_at_its_own_angle() { + let off_centre = [0.0, 0.0, 300.0, 200.0]; + for i in 1..900 { + let deg = f64::from(i) / 10.0; + for start in [CANVAS, off_centre] { + let f = kept_inside(start, deg, W, H).expect("a frame"); + assert_eq!(kept_inside(f, deg, W, H), Some(f), "{start:?} at {deg}°"); + } + } +} + +/// Not measured, so they keep today's behaviour: with Fill set to Content-Aware Fill, with a +/// frame already reaching past the canvas at the press, and in Classic Mode (where the frame +/// turns over the image) the frame keeps its size and place. +#[test] +fn content_aware_fill_a_frame_past_the_canvas_and_classic_mode_keep_the_frame() { + let past = [-50.0, 100.0, 300.0, 300.0]; + for (what, start) in [("content-aware", CANVAS), ("past the canvas", past), ("classic", CANVAS)] { + let (mut app, ctx) = app(start); + app.ui.tool_options.crop_content_aware = what == "content-aware"; + app.ui.tool_options.crop_shield.classic_mode = what == "classic"; + turn(&mut app, &ctx, start, &[(6.3, plain())], |_, _| {}); + assert!(angle(&app).abs() > 6.0, "{what}: turned, {}", angle(&app)); + assert_eq!(frame(&app), start, "{what}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..9d9116dbbc 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_popup_keys; +mod crop_turn_keeps_frame_inside; mod custom_print_paper; mod display_shadows; mod drag_preview_canvas; From c466cac6196a61545bf6c3fd0e2bf5d35e5a4d2a Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 3 Nov 2026 22:56:21 +0100 Subject: [PATCH 39/42] Batch: H/P and Info panel tests pick the Crop tool again after a commit #10642 hides the frame after a committed crop until the next press or re-picking the tool. The #9855 and #10453 tests expected the default frame right after the commit; they now check that it is hidden, pick the tool again and then test the default frame as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/crop_mode/tests.rs | 10 +++++++--- crates/ui-egui/src/tools/crop_ui/tests.rs | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 278832a735..a676cd6e5f 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -579,8 +579,9 @@ fn p_toggles_classic_mode_while_a_crop_is_in_progress() { assert!(!classic(&app)); } -/// After a commit the tool shows its untouched default frame again: H and P are the Hand and Pen -/// keys there, as when the tool is picked, not Show Cropped Area and Classic Mode (#9731, #6536). +/// After a commit the tool shows no frame until the next press or re-picking the tool, which +/// brings its untouched default frame back: H and P are the Hand and Pen keys there, as when the +/// tool is picked, not Show Cropped Area and Classic Mode (#9731, #6536). #[test] fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { for (k, tool) in [(egui::Key::H, Tool::Hand), (egui::Key::P, Tool::Pen)] { @@ -589,7 +590,10 @@ fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { key(&mut app, egui::Key::Enter, NONE); assert_eq!(app.session.active().map(|st| st.doc.size), Some(Size::new(60, 40)), "the crop committed"); crate::crop_ui::ensure_frame(&mut app); - assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "the default frame is back"); + assert!(app.ui.crop_rect.is_none(), "the commit hides the frame"); + crate::tool_memory::select(&mut app, Tool::Crop); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "re-picking the tool brings the default frame back"); key(&mut app, k, NONE); assert_eq!(app.ui.tool, tool, "{k:?}"); let s = &app.ui.tool_options.crop_shield; diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 9a93cebe5f..0eb5d13434 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -1282,7 +1282,8 @@ fn info_size_is_shown_in_the_ruler_unit() { /// #3145 review: after the crop is cancelled or committed, or the tool is left, W and H are the /// selection's again. With the Crop tool still picked, the new default frame is the selection's -/// bounds (or the canvas), as in Photoshop, so the panel shows the same size. +/// bounds (or the canvas), as in Photoshop, so the panel shows the same size. After a commit that +/// frame waits for the next press or re-picking the tool. #[test] fn info_size_returns_to_the_selection_after_cancel_or_commit() { let mut app = app(SampleType::U8); @@ -1304,6 +1305,9 @@ fn info_size_returns_to_the_selection_after_cancel_or_commit() { assert_eq!(app.session.active().unwrap().doc.size, Size::new(40, 30)); assert_eq!(info_size(&app), None, "committed: no frame"); ensure_frame(&mut app); + assert_eq!(info_size(&app), None, "committed: the frame stays hidden"); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); assert_eq!(info_size(&app), Some([40.0, 30.0]), "the new frame is the cropped canvas"); // Leaving the tool: the panel shows the selection, whatever the frame. app.ui.tool = Tool::Move; From 37c84c18e5e0180f840953bd09662f77c6a4cc4b Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 3 Nov 2026 23:39:48 +0100 Subject: [PATCH 40/42] Batch: Ctrl-held Straighten test expects transparent corners with Delete Cropped Pixels off Main's #10905 (#10867) turns the Background into Layer 0 when a crop keeps the cropped pixels, so the corners a turn uncovers stay transparent instead of taking the background colour. The #10950 test now expects that for its Delete Cropped Pixels off cases; with it on the corners are still the background colour. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/crop_ctrl_straighten.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs index 185d30bf55..643b3ad88f 100644 --- a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -131,9 +131,11 @@ fn a_command_held_straighten_line_keeps_the_frame_size() { let size = (frame[2] - frame[0], frame[3] - frame[1]); assert_eq!(committed(&mut app), (size.0 as u32, size.1 as u32), "{what}"); // The corners the turn uncovered take the background colour, the others and the - // middle are the image. + // middle are the image. With Delete Cropped Pixels off the Background becomes + // Layer 0 (#10867), so the uncovered corners stay transparent. for (p, out) in corners(&app).into_iter().zip(outside) { - assert!(if out { is_blue(p) } else { is_white(p) }, "{what}: a corner {} the canvas is {p:?}", if out { "off" } else { "on" }); + let uncovered = if delete { is_blue(p) } else { p[3] < 0.02 }; + assert!(if out { uncovered } else { is_white(p) }, "{what}: a corner {} the canvas is {p:?}", if out { "off" } else { "on" }); } assert_eq!(outside, if ratio.is_empty() { [true; 4] } else { [false, true, false, true] }, "{what}"); let mid = pixel(&app, (size.0 / 2.0) as usize, (size.1 / 2.0) as usize); From 75a6828f5336656ef387c100b4bd7abcd308aefe Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 4 Nov 2026 00:13:42 +0100 Subject: [PATCH 41/42] Batch: picking the Crop tool again keeps a committed crop's frame hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured in the reference application: after ↵ commits a crop, the Crop tool shows no frame; pressing C again, or another tool and back, keeps it hidden, and only a press on the canvas brings the frame back. A crop committed by leaving the tool is different: back on the Crop tool the idle frame shows at once. #10642 brought the frame back on re-picking; picking the tool no longer ends the hidden state, and a commit on leaving the tool does not start it. Tests follow. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tool_memory.rs | 4 --- crates/ui-egui/src/tools/crop_mode/tests.rs | 18 +++++-------- crates/ui-egui/src/tools/crop_ui.rs | 9 ++++--- crates/ui-egui/src/tools/crop_ui/tests.rs | 30 ++++++++++++++------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 1464052aad..2b05c523d6 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -26,10 +26,6 @@ pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; app.ui.spring = None; app.ui.tool = tool; - // Picking the Crop tool, even the one already picked, brings back a frame a commit hid. - if tool == Tool::Crop { - crate::crop_ui::show_frame(app); - } } /// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index a676cd6e5f..4401e9b15d 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -579,9 +579,9 @@ fn p_toggles_classic_mode_while_a_crop_is_in_progress() { assert!(!classic(&app)); } -/// After a commit the tool shows no frame until the next press or re-picking the tool, which -/// brings its untouched default frame back: H and P are the Hand and Pen keys there, as when the -/// tool is picked, not Show Cropped Area and Classic Mode (#9731, #6536). +/// After a commit the tool shows no frame until the next press, which brings its untouched default +/// frame back: H and P are the Hand and Pen keys there, as when the tool is picked, not Show +/// Cropped Area and Classic Mode (#9731, #6536). #[test] fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { for (k, tool) in [(egui::Key::H, Tool::Hand), (egui::Key::P, Tool::Pen)] { @@ -591,9 +591,8 @@ fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { assert_eq!(app.session.active().map(|st| st.doc.size), Some(Size::new(60, 40)), "the crop committed"); crate::crop_ui::ensure_frame(&mut app); assert!(app.ui.crop_rect.is_none(), "the commit hides the frame"); - crate::tool_memory::select(&mut app, Tool::Crop); - crate::crop_ui::ensure_frame(&mut app); - assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "re-picking the tool brings the default frame back"); + crate::crop_ui::show_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "a press brings the default frame back"); key(&mut app, k, NONE); assert_eq!(app.ui.tool, tool, "{k:?}"); let s = &app.ui.tool_options.crop_shield; @@ -740,11 +739,8 @@ fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() { let v = &mut app.ui.views[0]; (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0); app.ui.extras.snap = true; - // Re-picking the tool brings back the default frame the commit hid. - app.ui.tool = Tool::Move; - crate::crop_ui::ensure_frame(&mut app); - app.ui.tool = Tool::Crop; - crate::crop_ui::ensure_frame(&mut app); + // A press brings back the default frame the commit hid. + crate::crop_ui::show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); if ratio { app.ui.tool_options.crop_ratio = "original".into(); diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 4bd756dcd8..74c6add2f7 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -381,7 +381,6 @@ pub fn ensure_frame(app: &mut PhotocraftApp) { fn frame(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop { app.crop.editing = false; - app.crop.dismissed = None; crate::crop_straighten::reset(app); // Leaving the tool drops an untouched default frame and, as in Photoshop, commits a // drawn one (#3368). @@ -449,6 +448,9 @@ fn commit_on_leave(app: &mut PhotocraftApp) { cancel(app); } app.ui.tool = tool; + // Committed by leaving the tool: back on it the default frame shows at once, as in Photoshop + // (only a commit with the Crop tool still picked hides it). + app.crop.dismissed = None; } /// A crop gesture is in progress: Space repositions the frame rather than panning. @@ -471,8 +473,9 @@ pub fn cancel(app: &mut PhotocraftApp) { } /// After a committed crop, with the Crop tool picked: no new default frame for the cropped -/// document until a press on the canvas or re-picking the tool, so its corners don't make the -/// applied crop look still pending. +/// document until a press on the canvas (or a ratio), so its corners don't make the applied crop +/// look still pending. Picking the tool again, also after another tool, keeps it hidden, as in +/// Photoshop. pub fn hide_frame(app: &mut PhotocraftApp) { if app.ui.tool == Tool::Crop { app.crop.dismissed = dismiss_key(app); diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 0eb5d13434..280b854c00 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -122,14 +122,16 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { assert_eq!(doc.size, Size::new(30, 40), "{depth:?}"); assert_eq!(doc.depth, depth); assert!(doc.selection.is_none(), "cropping to the selection deselects, as in Photoshop"); - // No frame until a press or re-picking the tool; then the default one is the whole - // (cropped) canvas, and ↵ on it does nothing. + // No frame until a press, also after another tool and back; then the default one is the + // whole (cropped) canvas, and ↵ on it does nothing. ensure_frame(&mut app); assert_eq!(app.ui.crop_rect, None); app.ui.tool = Tool::Move; ensure_frame(&mut app); - app.ui.tool = Tool::Crop; + crate::tool_memory::select(&mut app, Tool::Crop); ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden"); + show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 30.0, 40.0])); let steps = app.session.active().unwrap().history.past_len(); crate::canvas::commit_crop(&mut app); @@ -138,10 +140,10 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { } } -/// A commit hides the frame; picking the Crop tool again, a ratio, or a press brings back the -/// default frame, and the press's crop keeps its box Undo and Redo. +/// A commit hides the frame; picking the Crop tool again keeps it hidden, as in Photoshop; a ratio +/// or a press brings back the default frame, and the press's crop keeps its box Undo and Redo. #[test] -fn a_hidden_frame_comes_back_on_re_picking_a_ratio_or_a_press_with_redo_intact() { +fn a_hidden_frame_comes_back_on_a_ratio_or_a_press_with_redo_intact() { let committed = || { let mut app = app(SampleType::U8); app.ui.crop_rect = Some([20.0, 10.0, 120.0, 60.0]); @@ -151,9 +153,17 @@ fn a_hidden_frame_comes_back_on_re_picking_a_ratio_or_a_press_with_redo_intact() app }; let whole = Some([0.0, 0.0, 100.0, 50.0]); - // Picking the Crop tool while it is still picked. + // Picking the Crop tool while it is still picked, or after another tool: still hidden. let mut app = committed(); crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "re-picked"); + crate::tool_memory::select(&mut app, Tool::Move); + ensure_frame(&mut app); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picked after another tool"); + show_frame(&mut app); assert_eq!(app.ui.crop_rect, whole); assert!(app.crop.default_frame); // A ratio fits the frame it brings back. @@ -1283,7 +1293,7 @@ fn info_size_is_shown_in_the_ruler_unit() { /// #3145 review: after the crop is cancelled or committed, or the tool is left, W and H are the /// selection's again. With the Crop tool still picked, the new default frame is the selection's /// bounds (or the canvas), as in Photoshop, so the panel shows the same size. After a commit that -/// frame waits for the next press or re-picking the tool. +/// frame waits for the next press. #[test] fn info_size_returns_to_the_selection_after_cancel_or_commit() { let mut app = app(SampleType::U8); @@ -1306,8 +1316,8 @@ fn info_size_returns_to_the_selection_after_cancel_or_commit() { assert_eq!(info_size(&app), None, "committed: no frame"); ensure_frame(&mut app); assert_eq!(info_size(&app), None, "committed: the frame stays hidden"); - crate::tool_memory::select(&mut app, Tool::Crop); - ensure_frame(&mut app); + // A press brings it back. + show_frame(&mut app); assert_eq!(info_size(&app), Some([40.0, 30.0]), "the new frame is the cropped canvas"); // Leaving the tool: the panel shows the selection, whatever the frame. app.ui.tool = Tool::Move; From c430647bb0f17eae996d09a1749ab252c620ebaf Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 4 Nov 2026 00:18:12 +0100 Subject: [PATCH 42/42] Batch: drop the Info panel's crop Ratio line from #10453 The reference application's Info panel shows the crop frame's W and H but no ratio line, so it stays out for now; W and H keep following the frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/info.rs | 8 ------ crates/ui-egui/src/tools/crop_ui.rs | 25 ------------------ crates/ui-egui/src/tools/crop_ui/tests.rs | 32 ----------------------- 3 files changed, 65 deletions(-) diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index f3a6572081..eeca9db7e3 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -49,8 +49,6 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). let crop = crate::crop_ui::info_size(app); let wh = crop.or(sel); - // And the frame's ratio, to match another photo's (#3145). - let ratio = crop.and_then(crate::crop_ui::ratio_label); let pos = hover_pixel(app); // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). @@ -90,12 +88,6 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let [w, h] = wh.map_or_else(|| [dash(), dash()], |s| wh_readout(&units_wh, dpi, [size.width as f64, size.height as f64], s)); row(&mut cols[1], "W:", w); row(&mut cols[1], "H:", h); - if let Some(r) = ratio { - cols[1].horizontal(|ui| { - ui.label(RichText::new(format!("{}:", tl!("Ratio"))).color(t.text_dim).size(11.5)); - ui.label(RichText::new(r).font(mono.clone()).color(t.text)); - }); - } }); widgets::hairline(ui); ui.label( diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 74c6add2f7..024e40b5bd 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -738,31 +738,6 @@ pub fn info_size(app: &PhotocraftApp) -> Option<[f64; 2]> { frame_size(app.ui.crop_rect?) } -/// The Info panel's ratio for a frame of `size` (whole px, as [`info_size`] gives): reduced to -/// whole numbers when both are small ("4:3", "16:9", "1:1"), else as a decimal against 1 on the -/// short side ("1.413:1", "1:1.5"), so a photo's ratio can be matched (#3145). -pub fn ratio_label(size: [f64; 2]) -> Option { - let [w, h] = size; - if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) { - return None; - } - // Whole px from `frame_size`; `as` saturates for huge values and the gcd stays positive. - let (a, b) = (w.round() as u64, h.round() as u64); - let (mut x, mut y) = (a, b); - while y != 0 { - (x, y) = (y, x % y); - } - let (rw, rh) = (a / x.max(1), b / x.max(1)); - if rw <= 32 && rh <= 32 { - return Some(format!("{rw}:{rh}")); - } - let trim = |v: f64| { - let s = format!("{v:.3}"); - s.trim_end_matches('0').trim_end_matches('.').to_string() - }; - Some(if w >= h { format!("{}:1", trim(w / h)) } else { format!("1:{}", trim(h / w)) }) -} - /// The Crop tool owns the arrow keys and X: a pending frame and no gesture in progress. pub fn takes_keys(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_none() && app.ui.transform.is_none() && app.ui.crop_rect.is_some_and(|r| r.iter().all(|v| v.is_finite())) diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 280b854c00..52a6295a75 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -1236,38 +1236,6 @@ fn info_panel_shows_the_pending_frame_size() { assert_eq!(info_size(&app), None, "another tool shows the selection instead"); } -/// #3145: the Info panel's crop ratio, reduced when small, else a decimal against 1. -#[test] -fn ratio_label_reduces_or_shows_a_decimal() { - assert_eq!(ratio_label([400.0, 300.0]).as_deref(), Some("4:3")); - assert_eq!(ratio_label([1920.0, 1080.0]).as_deref(), Some("16:9")); - assert_eq!(ratio_label([500.0, 500.0]).as_deref(), Some("1:1")); - assert_eq!(ratio_label([800.0, 1000.0]).as_deref(), Some("4:5")); - assert_eq!(ratio_label([1413.0, 1000.0]).as_deref(), Some("1.413:1")); - assert_eq!(ratio_label([200.0, 301.0]).as_deref(), Some("1:1.505")); - assert_eq!(ratio_label([1000.0, 1.0]).as_deref(), Some("1000:1")); - // Broken or absurd sizes never panic. - assert_eq!(ratio_label([f64::NAN, 1.0]), None); - assert_eq!(ratio_label([0.0, 1.0]), None); - assert!(ratio_label([f64::MAX, 1.0]).is_some()); -} - -/// #3145 review: the ratio of a frame that is 16:9 only after rounding to whole px, of a frame -/// near 16:9 but not on it, and of degenerate frames (under a pixel, or a line). -#[test] -fn ratio_label_rounds_and_rejects_degenerate_frames() { - assert_eq!(ratio_label([1920.4, 1079.6]).as_deref(), Some("16:9"), "rounded to whole px first"); - assert_eq!(ratio_label([1366.0, 768.0]).as_deref(), Some("1.779:1"), "near 16:9 is not 16:9"); - assert_eq!(ratio_label([1.0, 1.0]).as_deref(), Some("1:1")); - assert_eq!(ratio_label([0.4, 100.0]), None, "under a pixel wide"); - assert_eq!(ratio_label([100.0, 0.0]), None, "a line"); - assert_eq!(ratio_label([-16.0, -9.0]), None, "negative"); - assert_eq!(ratio_label([f64::INFINITY, 9.0]), None); - // A frame `frame_size` floors to 1 px stays labelled, never a division by zero. - let tiny = frame_size([0.0, 0.0, 0.2, 0.4]).unwrap(); - assert_eq!(ratio_label(tiny).as_deref(), Some("1:1")); -} - /// #3145 review: the crop frame's W and H use the Info panel's formatter for the ruler unit, /// the same one the selection's go through. #[test]