Merge pull request #11157 from davidmann4/integration-batch-14

Integration batch 14: 9 PRs planned
This commit is contained in:
David Mann authored and GitHub committed 2026-11-04 01:42:35 +01:00
commit 205db62a29
52 files changed
+2996 -103

No files matched your search

+2 -1
View File
@@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 |
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M19 5L5 19" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M10.35 5V19M13.65 5V19M5 10.35H19M5 13.65H19" />
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M15.31 10.14L15.33 10.26L15.33 10.38L15.30 10.52L15.23 10.65L15.14 10.77L15.01 10.87L14.85 10.96L14.67 11.00L14.46 11.01L14.25 10.98L14.04 10.89L13.83 10.75L13.65 10.56L13.51 10.32L13.41 10.04L13.37 9.72L13.40 9.38L13.52 9.03L13.71 8.70L13.99 8.39L14.35 8.13L14.79 7.94L15.29 7.85L15.83 7.86L16.39 8.00L16.95 8.27L17.46 8.68L17.91 9.22L18.25 9.89L18.46 10.67L18.50 11.52L18.35 12.42L17.98 13.33L17.40 14.19L16.59 14.96L15.57 15.58L14.37 15.99L13.03 16.15L11.58 16.01L10.11 15.54L8.68 14.71L7.38 13.52L6.29 11.99L5.50 10.14" />
</svg>

After

Width:  |  Height:  |  Size: 889 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M8.5 5V19M12 5V19M15.5 5V19M5 8.5H19M5 12H19M5 15.5H19" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M9.67 5V19M14.33 5V19M5 9.67H19M5 14.33H19" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M5 19L12 12" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+13
View File
@@ -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
+8 -3
View File
@@ -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<Prepared> {
// 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::<Vec<_>>();
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);
@@ -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<GalleryEffect>) -> 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"
);
}
}
+1
View File
@@ -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;
+9 -7
View File
@@ -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",
+1
View File
@@ -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;
+5 -1
View File
@@ -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",
+123 -1
View File
@@ -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<Descriptor> {
};
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<Vec<[f64; 2]>> {
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<Vec<[f64; 2]>> {
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<Descriptor> {
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)))
}
@@ -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<Value> {
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<Descriptor> {
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<Mapped> {
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<Descriptor> {
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, &parameters(id, &source)?)?;
Some(if original == canonical { source } else { canonical })
}
@@ -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<Descriptor> {
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());
}
+19
View File
@@ -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:?}");
}
}
}
+6 -2
View File
@@ -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 {
@@ -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}");
}
}
+505
View File
@@ -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<AtnItem>) -> AtnFile {
AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
..Default::default()
}
}
fn action(items: Vec<AtnItem>) -> 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<AtnItem> {
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<f32> =
(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);
}
}
+258
View File
@@ -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<Descriptor>) -> 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<AtnItem>) -> 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<AtnItem> {
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::<Vec<_>>()});
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<f32> = (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);
}
}
}
+2
View File
@@ -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;
@@ -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);
}
@@ -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());
+1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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");
}
+7
View File
@@ -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")),
+1 -1
View File
@@ -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;
+14 -3
View File
@@ -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(
+34 -17
View File
@@ -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<String>,
@@ -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<Filter, String> {
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<Filter, Strin
.filter(|m| *m == BlendMode::PassThrough || BlendMode::LAYER_MODES.contains(m))
.ok_or_else(|| format!("layerFilter.mode: unknown blend mode `{name}`"))?;
}
if let Some(e) = o.get("effect") {
f.effect = effect::parse(e)?;
}
if let Some(color) = o.get("color") {
let id = color.as_str().ok_or("layerFilter.color must be a label colour id")?;
f.color = LabelColor::ALL.into_iter().find(|c| c.id() == id).ok_or_else(|| {
@@ -215,6 +225,7 @@ pub fn apply(ui: &mut UiState, f: Filter) {
ui.layer_filter = f.kinds;
ui.layer_filter_name = f.name;
ui.layer_filter_mode = f.mode;
ui.layer_filter_effect = f.effect;
ui.layer_filter_color = f.color;
ui.layer_filter_smart = f.smart;
}
@@ -238,6 +249,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let by = [
(FilterBy::Kind, tl!("Kind")),
(FilterBy::Name, tl!("Name")),
(FilterBy::Effect, tl!("Effect")),
(FilterBy::Mode, tl!("Mode")),
(FilterBy::Color, tl!("Color")),
(FilterBy::SmartObject, tl!("Smart Object")),
@@ -268,6 +280,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let width = ui.available_width().max(48.0);
widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false);
}
FilterBy::Effect => effect::controls(app, ui),
FilterBy::Mode => {
let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect();
let width = ui.available_width().max(96.0);
@@ -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;
@@ -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<EffectKind, String> {
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);
}
@@ -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<String> {
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<String> {
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"]);
}
+4
View File
@@ -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,
+70 -1
View File
@@ -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).
+28 -4
View File
@@ -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();
+32 -7
View File
@@ -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> {
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();
}
+14 -4
View File
@@ -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;
+22
View File
@@ -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.
+26 -14
View File
@@ -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;
}
+59 -4
View File
@@ -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<crate::crop_mode::PressView> },
/// 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<crate::crop_mode::PressView> },
/// `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<crate::crop_mode::PressView> },
}
/// 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 }) => {
+130 -3
View File
@@ -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);
}
@@ -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<LayerId>,
/// 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<LayerId>,
/// 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<LayerId> = 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<LayerId>) {
/// 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<LayerId>, applied: &mut Vec<LayerId>) {
// Adjustments met since the last covering layer: they count once one lies under them.
let mut pending: Vec<LayerId> = 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;
}
}
@@ -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).
@@ -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::<f64>() / n, red.iter().map(|p| p.1).sum::<f64>() / 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:?}");
}
}
}
}
@@ -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);
}
@@ -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<egui::TextureId>) {
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<State>(h: &Harness<'_, State>) -> HashSet<egui::TextureId> {
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::<Vec<_>>(), 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<State>(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:?}");
}
@@ -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}");
}
}
+3
View File
@@ -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;
+39 -5
View File
@@ -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 |
+1 -1
View File
@@ -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=<file.csv>` / `--stroke-trace <file.csv>`): 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.
+3 -2
View File
@@ -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 Line truncated
> **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 wLine truncated
**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 |