mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
Merge pull request #11157 from davidmann4/integration-batch-14
Integration batch 14: 9 PRs planned
This commit is contained in:
52 files changed
+2996
-103
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ¶meters(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());
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user