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/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..a2c98e3c39 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`]). @@ -540,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) } @@ -562,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 new file mode 100644 index 0000000000..32d4356267 --- /dev/null +++ b/crates/algo/tests/it/gallery_reach_extreme.rs @@ -0,0 +1,76 @@ +//! 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, 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]; + +/// 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}"); + } +} + +/// 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" + ); + } +} 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; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 5c74340355..f1fc5b1146 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -369,13 +369,15 @@ 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", "mode", - /// "color", "smartObject" or "artboard", which lists the artboards that hold the selected - /// layers), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend - /// mode name), color (a layer colour label, or "none") and smart (a list of "linked", "missing" - /// and "embedded") 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", + /// "mode", "color", "smartObject" or "artboard", which lists the artboards that hold the + /// selected layers), kinds (a list of layer kinds), name (matches part of the layer name), + /// effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay", + /// "outerGlow" or "dropShadow"), mode (a blend mode name), color (a layer colour label, or + /// "none") and smart (a list of "linked", "missing" and "embedded") 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/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 41078987cd..d479606938 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -41,6 +41,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 87d327c2e5..b611026809 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -7,7 +7,7 @@ use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to}; 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_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_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, @@ -23,6 +23,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}; @@ -548,6 +549,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", @@ -763,6 +765,7 @@ 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 }, target_event!("reverse", "Reverse", "layer.arrange.reverse"), target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"), target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"), @@ -797,6 +800,7 @@ pub(super) const EVENTS: &[Event] = &[ ids: &[ "type.convertToShape", "layer.new.layer", + "layer.layerStyle.createLayer", "layer.new.group", "layer.groupLayers", "layer.duplicate", diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 86ffa239da..703b2327dd 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,125 @@ 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))) } + +// 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 { + (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) +} + +/// 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 }; + if !(2..=MAX_SHEAR_POINTS).contains(&values.len()) { + 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() +} + +/// 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 = || { + 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) => shear_json_points(value)?, + }; + if !shear_curve(&points) { + return None; + } + 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) => shear_json_points(value)?, + }; + // 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()?; + 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/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs new file mode 100644 index 0000000000..4b85127cf4 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -0,0 +1,130 @@ +//! 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) +} + +/// 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)| { + // 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. + 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)) +} + +/// 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 9ce3bbb8f7..b45ceddabf 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -179,6 +179,9 @@ fn type_shape_descriptor() -> Descriptor { /// Import Make events only when their geometry and options can be represented faithfully, /// including the exact target-text-to-content-layer references. pub(super) fn make_from(d: &Descriptor) -> Mapped { + if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) { + return mapped; + } if d.items == type_shape_descriptor().items { return none("type.convertToShape"); } @@ -311,6 +314,9 @@ fn recorded_percent(opacity: f64) -> f64 { /// Write a creation step, refusing native options the target descriptor cannot carry; Type to /// Shape exports only without native options. 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); + } if id == "type.convertToShape" { return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor()); } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f468f72432..2b32e02220 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -527,6 +527,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"})), @@ -754,6 +755,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"})), @@ -6044,3 +6047,19 @@ 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); } } + +/// 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] { + 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:?}"); + } + } +} diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index c322da8e26..55f3d9a39e 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 7bf247466b..00274b7072 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -907,3 +907,64 @@ 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:?}"); +} + +#[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}"); + } +} 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..38db75621e --- /dev/null +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -0,0 +1,505 @@ +//! Measured layer-style action steps retain their settings and use the existing native commands. +use photocraft_doc::{Document, Effect, 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, MAX_DEPTH, ObjectArray, 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() +} + +/// 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(); + 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.as_ref(), before); + for _ in 0..edits { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), 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, 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(); + 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; + 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()); + } + 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))); + 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)))); + } + 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 ") }, + ]), + 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; + 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; + 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(); + 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 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() { + 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); + } +} + +/// 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 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 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); + } +} diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs new file mode 100644 index 0000000000..fb6e605d4d --- /dev/null +++ b/crates/engine/tests/it/atn_shear.rs @@ -0,0 +1,258 @@ +//! 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; + 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(); + 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)); + 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() { + 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"}), + 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()); + 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 8e8553d509..392b7ba517 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -4,10 +4,12 @@ mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; mod atn_direct_adjustments; +mod atn_layer_style; mod atn_newer_events; 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; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 146c3a1990..dc1a2b7c44 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -1188,7 +1188,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/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index f07b5e1966..783f570bca 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -1138,6 +1138,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 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); + 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_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] 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/control/tests.rs b/crates/ui-egui/src/control/tests.rs index dc630df23c..bfb2bf4f1a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() { assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); assert_eq!(bad["ok"], false, "{bad}"); - assert!(bad.to_string().contains("kind, name, mode, color, smartObject or artboard"), "{bad}"); + assert!(bad.to_string().contains("kind, name, effect, mode, color, smartObject or artboard"), "{bad}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); } 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/panels.rs b/crates/ui-egui/src/panels.rs index 9a5266684c..b728ef25d3 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 56a60e82d0..eeca9db7e3 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,8 +43,12 @@ 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); + 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 crop = crate::crop_ui::info_size(app); + let wh = crop.or(sel); 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). @@ -75,8 +85,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:", 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))); + 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); }); widgets::hairline(ui); ui.label( diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index e7916010e8..0634d39918 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,8 +1,9 @@ //! 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, Color one layer colour label (#5898), Smart Object toggles linked, missing -//! and embedded smart objects, Artboard lists only the artboards that hold the selected layers. The -//! criteria live in `UiState` (`layer_filter*`), so the control channel can read and set them. +//! 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, Color one layer colour label (#5898), +//! Smart Object toggles linked, missing and embedded smart objects, Artboard lists only the +//! artboards that hold the selected layers. The criteria live in `UiState` (`layer_filter*`), so +//! the control channel can read and set them. use std::collections::HashSet; @@ -20,6 +21,7 @@ pub enum FilterBy { #[default] Kind, Name, + Effect, Mode, Color, SmartObject, @@ -34,6 +36,7 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub effect: effect::EffectKind, pub color: LabelColor, /// The Smart Object toggles that are on, from [`SMART`]. pub smart: Vec, @@ -51,6 +54,7 @@ impl Filter { kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode, + effect: ui.layer_filter_effect, color: ui.layer_filter_color, smart: ui.layer_filter_smart.clone(), missing: HashSet::new(), @@ -74,14 +78,15 @@ impl Filter { f } - /// Whether the panel is filtered. Like Photoshop, Mode and Color always filter (by Normal and - /// by no colour at first); Kind, Name and Smart Object only once a toggle is on or some text is - /// typed; Artboard only while some layers are selected and each of them is in an artboard. + /// Whether the panel is filtered. Like Photoshop, Effect, Mode and Color always filter (by + /// Bevel & Emboss, by Normal and by no colour at first); Kind, Name and Smart Object only once a + /// toggle is on or some text is typed; Artboard only while some layers are selected and each of + /// them is in an artboard. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode | FilterBy::Color => true, + FilterBy::Effect | FilterBy::Mode | FilterBy::Color => true, FilterBy::SmartObject => !self.smart.is_empty(), FilterBy::Artboard => !self.board_rows.is_empty(), } @@ -95,6 +100,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, FilterBy::Color => l.label == self.color, FilterBy::SmartObject => { @@ -151,20 +157,21 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; /// not there, and embedded in the document. pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; -/// `ui.set {layerFilter: {by?, kinds?, name?, mode?, color?, smart?}}`: `current` with the given -/// fields replaced, or an error naming the bad field (nothing changes then). `by` is `kind`, -/// `name`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of [`KINDS`]; `mode` a -/// blend mode name such as `Multiply` or `Pass Through`; `color` a label colour id (`none`, `red`, -/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its -/// own: the selected layers pick the artboards. +/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, color?, smart?}}`: `current` with +/// the given fields replaced, or an error naming the bad field (nothing changes then). `by` is +/// `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of +/// [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` a blend mode name such as `Multiply` +/// or `Pass Through`; `color` a label colour id (`none`, `red`, … `gray`, [`LabelColor::id`]); +/// `smart` a list of [`SMART`]. Artboard has no criteria of its own: the selected layers pick the +/// artboards. 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", "color", "smart"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode, color, smart)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "color", "smart"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, color, smart)")); } 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, mode, color, smartObject or artboard".to_string())?; + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, effect, mode, color, smartObject or artboard".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -187,6 +194,9 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result 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); @@ -302,5 +315,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 43539ecf89..71cce38f37 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -367,6 +367,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, /// Layers panel colour filter (#5898): the label colour listed (None: layers without one). #[serde(default)] pub layer_filter_color: photocraft_doc::LabelColor, @@ -582,6 +585,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(), layer_filter_color: Default::default(), pen: None, pen_doc: None, 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_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 5f059917c9..4401e9b15d 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,29 @@ fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { assert!(!classic(&app)); } +/// 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)] { + 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_none(), "the commit hides the frame"); + 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; + 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(); @@ -716,7 +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; - 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_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/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; diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index ee1b939e15..c9ce39f83d 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -232,6 +232,8 @@ pub fn clear_ratio(o: &mut ToolOptions) { clear(o); } else { o.crop_ratio.clear(); + o.crop_custom_ratio_width = 0.0; + o.crop_custom_ratio_height = 0.0; } } @@ -385,6 +387,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/src/tools/crop_straighten.rs b/crates/ui-egui/src/tools/crop_straighten.rs index 5f663f2e6b..939953fd02 100644 --- a/crates/ui-egui/src/tools/crop_straighten.rs +++ b/crates/ui-egui/src/tools/crop_straighten.rs @@ -8,11 +8,13 @@ //! - 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 -//! 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. +//! 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. use egui::{Modifiers, Pos2}; @@ -87,10 +89,12 @@ 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 (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); + apply(app, a, [x, y], zoom, fit); true } } @@ -129,15 +133,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 { + // 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 + } 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/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 003debc441..024e40b5bd 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). @@ -94,8 +98,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. @@ -268,6 +273,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); @@ -401,6 +408,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. @@ -436,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. @@ -457,6 +472,29 @@ 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 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); + } +} + +/// 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)) +} + /// 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(); @@ -690,6 +728,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())) @@ -820,6 +868,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 { .. }) { + show_frame(app); + } 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. @@ -839,6 +892,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]) { + show_frame(app); claim(app); app.crop.editing = true; app.crop.press_step = Some(step_of(app)); @@ -875,7 +929,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 } @@ -943,13 +997,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/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index d70e2f5792..52a6295a75 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -122,8 +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"); - // The new default frame is the whole (cropped) canvas: ↵ 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); + 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); @@ -132,6 +140,49 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { } } +/// 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_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, 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. + 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); @@ -152,7 +203,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); @@ -872,7 +923,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)); } @@ -1165,3 +1216,79 @@ fn readout_shows_the_frame_size_while_drawing_or_resizing() { 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"); +} + +/// #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. After a commit that +/// frame waits for the next press. +#[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), None, "committed: the frame stays hidden"); + // 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; + ensure_frame(&mut app); + assert_eq!(info_size(&app), None); +} diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index 9c344caa4a..fb6db2c113 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 cdc178e697..72bee56075 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -1999,6 +1999,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; @@ -2006,7 +2053,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 @@ -2019,7 +2066,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]; @@ -2033,20 +2080,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). 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..643b3ad88f --- /dev/null +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -0,0 +1,305 @@ +//! 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 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]; + +/// 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); + (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}"); +} + +/// 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]}, + {"kind": "move", "x": mid[0], "y": mid[1]}, + {"kind": "move", "x": b[0], "y": b[1]}, + {"kind": "up", "x": b[0], "y": b[1]}, + ]); + 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] { + 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 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 + // 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) { + 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); + 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:?}"); + } + } + } + } +} + +/// 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 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, ""); + // 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 (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:?}"); + } + } +} + +/// ⌘ 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:?}"); + } + } + } +} diff --git a/crates/ui-egui/tests/it/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs index 58d9709e99..4161631529 100644 --- a/crates/ui-egui/tests/it/crop_default_mode.rs +++ b/crates/ui-egui/tests/it/crop_default_mode.rs @@ -239,3 +239,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); +} 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..2cbc8195e6 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -0,0 +1,166 @@ +//! #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::{Tool, 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<'_, State>) -> HashSet { + let mut ids = HashSet::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut ids); + } + 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)) + .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"); +} + +/// 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 + // 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:?}"); +} + +/// 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| { + 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 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 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:?}"); +} 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 90db2f9773..94ef9ddee0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -12,9 +12,12 @@ mod checker_canvas; mod clipboard_svg; mod color_managed_canvas; mod crop_beyond_canvas; +mod crop_ctrl_straighten; mod crop_default_mode; +mod crop_overlay_icons; mod crop_popup_keys; mod crop_shift_corner; +mod crop_turn_keeps_frame_inside; mod custom_print_paper; mod display_shadows; mod drag_preview_canvas; diff --git a/docs/atn.md b/docs/atn.md index eec29cf7e0..64c6033bf5 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 (168) +## Supported events | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -99,7 +99,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`, `type.convertToShape` | +| `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`, `type.convertToShape` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | @@ -129,6 +129,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` | @@ -149,6 +150,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` | @@ -246,6 +248,27 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step. Playback stops at that enabled helper before subsequent duplicate/crop steps can run. This covers the parent event, not complete recordings with generated helpers. +### 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 @@ -523,8 +546,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) | | @@ -1469,7 +1490,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 @@ -1495,6 +1516,19 @@ 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. +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. + | 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/control-protocol.md b/docs/control-protocol.md index b569e68189..4a3c55d6ec 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?, color?, smart?}`, any subset. `by` is `kind`, `name`, `mode`, `color`, `smartObject` or `artboard` (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`); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Mode and Color always filter, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. 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?, effect?, mode?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard` (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; `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; `mode` is a blend mode name (`Multiply`, `Pass Through`); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode and Color always filter, by Bevel & Emboss, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. 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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 48b1929872..bc3c84a524 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-11-02 · **Change:** minor (TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.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-11-02 · **Change:** minor (TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.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 (146 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), recorded classic `GrMp` with fractional RGB stops and `RplC` with explicit Lab sample bounds (#10607); 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 (supported event IDs listed 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), recorded classic `GrMp` with fractional RGB stops and `RplC` with explicit Lab sample bounds (#10607); 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`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | | 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | | 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered |