mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 17:46:04 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
|
||||
Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32)
|
||||
}
|
||||
|
||||
/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the
|
||||
/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its
|
||||
/// old size, so when both axes shrink it is larger than the result (#9212).
|
||||
pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect {
|
||||
let area = |r: Rect| u64::from(r.width()) * u64::from(r.height());
|
||||
let (mut cur, mut largest) = (r, Rect::EMPTY);
|
||||
for p in plan(filter, sx, sy) {
|
||||
cur = scaled_rect(cur, p.sx, p.sy);
|
||||
if area(cur) > area(largest) {
|
||||
largest = cur;
|
||||
}
|
||||
}
|
||||
largest
|
||||
}
|
||||
|
||||
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
|
||||
/// default pixel is kept (masks keep their "reveal all" background).
|
||||
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
|
||||
@@ -346,7 +361,8 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>)
|
||||
|
||||
/// Runs `filter`'s passes. Between two passes the image is stored in its own format, which
|
||||
/// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so
|
||||
/// the intermediate image is never larger than the result.
|
||||
/// the intermediate image is never larger than the result when an axis grows, but is when both
|
||||
/// shrink ([`largest_resize_rect`]).
|
||||
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize) -> Surface {
|
||||
resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE)
|
||||
}
|
||||
|
||||
@@ -401,6 +401,25 @@ fn nearest_neighbor_reduction_does_not_average() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the
|
||||
/// old size, larger than the result; single-pass filters and growing axes store nothing larger.
|
||||
#[test]
|
||||
fn largest_resize_rect_counts_the_image_between_the_passes() {
|
||||
let r = Rect::new(0, 0, 100, 80);
|
||||
for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] {
|
||||
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}");
|
||||
}
|
||||
// The axis with the smaller scale goes first.
|
||||
assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20));
|
||||
for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] {
|
||||
assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}");
|
||||
}
|
||||
for f in [Resample::Bicubic, Resample::BicubicAutomatic] {
|
||||
assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows");
|
||||
assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controlled_resize_matches_pixels_and_discards_cancelled_work() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
@@ -778,6 +778,8 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
mod distance;
|
||||
#[cfg(test)]
|
||||
mod ellipse_aa_edge_tests;
|
||||
mod inverse;
|
||||
mod lasso;
|
||||
mod marquee;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! The Elliptical Marquee's anti-aliased edge against the reference measurement (#11391):
|
||||
//! document 400 × 350, ellipse 100,100–300,250, the patch x 127–132, y 119–124 on the 45° part
|
||||
//! of its top-left edge. Grids hold what a black fill of the selection reads there (255 outside,
|
||||
//! 0 fully selected), as the issue's tables do.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use super::ellipse_surface;
|
||||
|
||||
const AREA: Rect = Rect { x0: 0, y0: 0, x1: 400, y1: 350 };
|
||||
const ELLIPSE: Rect = Rect { x0: 100, y0: 100, x1: 300, y1: 250 };
|
||||
const PATCH: (i32, i32) = (127, 119);
|
||||
|
||||
/// The reference with Anti-alias on.
|
||||
const SOFT: [[u8; 6]; 6] = [
|
||||
[255, 255, 255, 255, 255, 152],
|
||||
[255, 255, 255, 212, 106, 0],
|
||||
[255, 255, 172, 64, 0, 0],
|
||||
[247, 137, 26, 0, 0, 0],
|
||||
[105, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
];
|
||||
|
||||
/// Anti-alias off: the reference and PhotoCraft before #11391 agree.
|
||||
const HARD: [[u8; 6]; 6] =
|
||||
[[255, 255, 255, 255, 255, 255], [255, 255, 255, 255, 0, 0], [255, 255, 255, 0, 0, 0], [255, 255, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]];
|
||||
|
||||
/// The grey a black fill of selection `s` gives at `(x, y)` on white.
|
||||
fn grey(s: &Surface, x: i32, y: i32) -> u8 {
|
||||
let v = s.pixel(x, y).first().copied().unwrap_or(0.0);
|
||||
(255.0 - v * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn patch(s: &Surface) -> [[u8; 6]; 6] {
|
||||
std::array::from_fn(|j| std::array::from_fn(|i| grey(s, PATCH.0 + i as i32, PATCH.1 + j as i32)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anti_aliased_patch_matches_the_reference_within_three_levels() {
|
||||
let got = patch(&ellipse_surface(ELLIPSE, AREA, true));
|
||||
for (j, (row, want)) in got.iter().zip(&SOFT).enumerate() {
|
||||
for (i, (g, w)) in row.iter().zip(want).enumerate() {
|
||||
assert!(g.abs_diff(*w) <= 3, "{},{}: {g} vs reference {w}\n{got:?}", PATCH.0 + i as i32, PATCH.1 + j as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_other_three_quadrants_mirror_the_measured_one() {
|
||||
let s = ellipse_surface(ELLIPSE, AREA, true);
|
||||
// Symmetric about x = 200 and y = 175: pixel x mirrors to 399 - x, y to 349 - y.
|
||||
for y in PATCH.1..PATCH.1 + 6 {
|
||||
for x in PATCH.0..PATCH.0 + 6 {
|
||||
let here = s.pixel(x, y);
|
||||
assert_eq!(s.pixel(399 - x, y), here, "{x},{y} mirrored across x");
|
||||
assert_eq!(s.pixel(x, 349 - y), here, "{x},{y} mirrored across y");
|
||||
assert_eq!(s.pixel(399 - x, 349 - y), here, "{x},{y} mirrored through the centre");
|
||||
}
|
||||
}
|
||||
// A few partial pixels, so the mirror is not of 0s and 1s alone.
|
||||
assert_eq!(grey(&s, 399 - 129, 122), grey(&s, 129, 122));
|
||||
assert!((1..255).contains(&grey(&s, 399 - 129, 349 - 121)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hard_edge_is_unchanged() {
|
||||
assert_eq!(patch(&ellipse_surface(ELLIPSE, AREA, false)), HARD);
|
||||
}
|
||||
|
||||
/// Fully inside reads exactly 1 and outside exactly 0, so Add / Subtract / Intersect combine as
|
||||
/// before; the edge is soft only in a band at most √2 px wide.
|
||||
#[test]
|
||||
fn inside_is_exactly_selected_and_outside_exactly_empty() {
|
||||
let s = ellipse_surface(ELLIPSE, AREA, true);
|
||||
// Every pixel whose square lies inside, here all of the axes but the end pixels (whose
|
||||
// corners poke out by a sliver).
|
||||
for x in 101..299 {
|
||||
assert_eq!(s.pixel(x, 175), vec![1.0], "{x},175 on the horizontal axis");
|
||||
}
|
||||
for y in 101..249 {
|
||||
assert_eq!(s.pixel(200, y), vec![1.0], "200,{y} on the vertical axis");
|
||||
}
|
||||
for (x, y) in [(100, 175), (299, 175), (200, 100), (200, 249)] {
|
||||
assert!(s.pixel(x, y)[0] > 0.99, "{x},{y}: the axis ends are all but full");
|
||||
}
|
||||
for (x, y) in [(99, 175), (300, 175), (200, 99), (200, 250), (100, 100), (299, 249), (0, 0)] {
|
||||
assert_eq!(s.pixel(x, y), vec![0.0], "{x},{y} outside");
|
||||
}
|
||||
let partial = (0..350).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| (1..255).contains(&grey(&s, x, y))).count();
|
||||
let (hard_in, soft_in) = {
|
||||
let h = ellipse_surface(ELLIPSE, AREA, false);
|
||||
let sum = |s: &Surface| s.read_region(AREA).iter().map(|v| f64::from(*v)).sum::<f64>();
|
||||
(sum(&h), sum(&s))
|
||||
};
|
||||
// The perimeter (≈ 556 px) times a band 1 to √2 px across, and the hard ellipse's area.
|
||||
assert!((556..787).contains(&partial), "{partial} partial pixels");
|
||||
let exact = std::f64::consts::PI * 100.0 * 75.0;
|
||||
assert!((soft_in - exact).abs() < 0.002 * exact && (hard_in - exact).abs() < 0.002 * exact, "{soft_in} {hard_in} vs {exact}");
|
||||
}
|
||||
@@ -30,14 +30,22 @@ fn decode8(b: u8) -> f32 {
|
||||
}
|
||||
|
||||
/// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee
|
||||
/// always has (so its anti-aliased edge keeps the same bytes).
|
||||
/// always has (so its hard edge keeps the same bytes).
|
||||
struct Ellipse {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
/// At most 3 px across: the anti-aliased edge keeps its 4×4 sub-samples (#11391 measured
|
||||
/// only larger ellipses).
|
||||
small: bool,
|
||||
/// `1 / rx`, `1 / ry`, `1 / rx²` and `1 / ry²` for [`Ellipse::ramp`].
|
||||
inv: [f64; 4],
|
||||
}
|
||||
|
||||
/// The widest an ellipse is across and still [`Ellipse::small`].
|
||||
const SMALL: u32 = 3;
|
||||
|
||||
impl Ellipse {
|
||||
fn new(r: Rect) -> Self {
|
||||
Self {
|
||||
@@ -45,42 +53,82 @@ impl Ellipse {
|
||||
cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0,
|
||||
rx: r.width() as f32 / 2.0,
|
||||
ry: r.height() as f32 / 2.0,
|
||||
small: r.width().min(r.height()) <= SMALL,
|
||||
inv: {
|
||||
let (rx, ry) = (f64::from(r.width()) / 2.0, f64::from(r.height()) / 2.0);
|
||||
[1.0 / rx, 1.0 / ry, 1.0 / (rx * rx), 1.0 / (ry * ry)]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The anti-aliased edge's coverage of pixel `(x, y)`, as Photoshop draws it (#11391): with
|
||||
/// `F` the normalised radius at the pixel's centre, `(F - 1) / (|∂F/∂x| + |∂F/∂y|)` is the
|
||||
/// centre's distance to the edge measured in the pixel's own width across the edge, and the
|
||||
/// coverage ramps linearly over that width. An axis-aligned edge gets the pixel's exact
|
||||
/// area; a 45° edge ramps over √2 px. 1 exactly at and around the centre.
|
||||
#[inline]
|
||||
fn ramp(&self, x: i32, y: i32) -> f64 {
|
||||
let [irx, iry, irx2, iry2] = self.inv;
|
||||
let (dx, dy) = (f64::from(x) + 0.5 - f64::from(self.cx), f64::from(y) + 0.5 - f64::from(self.cy));
|
||||
let (u, v) = (dx * irx, dy * iry);
|
||||
let f = (u * u + v * v).sqrt();
|
||||
// `f · (|∂F/∂x| + |∂F/∂y|)`; 0 only at the centre itself.
|
||||
let across = dx.abs() * irx2 + dy.abs() * iry2;
|
||||
if across <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
(0.5 - (f - 1.0) * f / across).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn inside(&self, x: f32, y: f32) -> bool {
|
||||
let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry);
|
||||
dx * dx + dy * dy <= 1.0
|
||||
}
|
||||
|
||||
/// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four
|
||||
/// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one
|
||||
/// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic).
|
||||
/// Whether pixel `(x, y)` is fully selected: its centre is inside (hard edge), its four
|
||||
/// corners are (a small anti-aliased ellipse), or its [`Ellipse::ramp`] is 1. Along a row
|
||||
/// the pixels where this holds form one run: each test grows with the distance from the
|
||||
/// centre; [`row_span`] checks both ends of the run.
|
||||
#[inline]
|
||||
fn full(&self, x: i32, y: i32, aa: bool) -> bool {
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
if !aa {
|
||||
return self.inside(fx + 0.5, fy + 0.5);
|
||||
}
|
||||
self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0)
|
||||
if self.small {
|
||||
return self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0);
|
||||
}
|
||||
self.ramp(x, y) >= 1.0
|
||||
}
|
||||
|
||||
/// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased)
|
||||
/// the number of its 4×4 sub-samples inside.
|
||||
/// The byte pixel `(x, y)` stores: `one` when [`Ellipse::full`], 0 outside a hard edge, and
|
||||
/// otherwise the fraction of its 4×4 sub-samples inside (small) or its [`Ellipse::ramp`].
|
||||
#[inline]
|
||||
fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 {
|
||||
fn coverage(&self, x: i32, y: i32, aa: bool, enc: &[u8; 17]) -> u8 {
|
||||
let one = enc[16];
|
||||
if aa && !self.small {
|
||||
// One ramp: a full pixel's is 1, which encodes as `one`.
|
||||
// `encode8`'s rounding, inline: this runs for every edge pixel.
|
||||
return (self.ramp(x, y) as f32 * 255.0 + 0.5) as u8;
|
||||
}
|
||||
if self.full(x, y, aa) {
|
||||
return 16;
|
||||
return one;
|
||||
}
|
||||
if !aa {
|
||||
return 0;
|
||||
}
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
(0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8
|
||||
let n = (0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count();
|
||||
enc.get(n).copied().unwrap_or(one)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes for 0/16 … 16/16 coverage.
|
||||
fn sixteenths() -> [u8; 17] {
|
||||
std::array::from_fn(|n| encode8(n as f32 / 16.0))
|
||||
}
|
||||
|
||||
/// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and
|
||||
/// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -94,7 +142,7 @@ struct RowSpan {
|
||||
/// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a
|
||||
/// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests
|
||||
/// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row.
|
||||
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
|
||||
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool, enc: &[u8; 17]) -> RowSpan {
|
||||
let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 };
|
||||
let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry));
|
||||
// Every point the tests sample in this row lies in `y..y + 1`.
|
||||
@@ -118,14 +166,15 @@ fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
|
||||
if inner < 0.0 || full_lo >= full_hi {
|
||||
(full_lo, full_hi) = (hi, hi);
|
||||
}
|
||||
let empty = |x: i32| e.coverage(x, y, aa) == 0;
|
||||
let empty = |x: i32| e.coverage(x, y, aa, enc) == 0;
|
||||
let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa)));
|
||||
if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact }
|
||||
}
|
||||
|
||||
/// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8
|
||||
/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside;
|
||||
/// without anti-aliasing a pixel is selected when its centre is inside.
|
||||
/// selection. Anti-aliased edge pixels ramp across the edge as in Photoshop ([`Ellipse::ramp`];
|
||||
/// an ellipse at most 3 px across keeps the fraction of 4×4 sub-samples inside); without
|
||||
/// anti-aliasing a pixel is selected when its centre is inside.
|
||||
pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let cut = r.intersect(&clip);
|
||||
@@ -133,9 +182,9 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
|
||||
return s;
|
||||
}
|
||||
let e = Ellipse::new(r);
|
||||
let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0));
|
||||
let enc = sixteenths();
|
||||
let one = encode8(1.0);
|
||||
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias));
|
||||
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias, &enc));
|
||||
let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?);
|
||||
let full = s.solid_tile(&[1.0]);
|
||||
let blank = s.solid_tile(&[0.0]);
|
||||
@@ -159,11 +208,11 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
|
||||
d.fill(one);
|
||||
}
|
||||
for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) {
|
||||
let n = e.coverage(x, y, anti_alias);
|
||||
let n = e.coverage(x, y, anti_alias, &enc);
|
||||
if n > 0
|
||||
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x))
|
||||
{
|
||||
*d = enc.get(usize::from(n)).copied().unwrap_or(one);
|
||||
*d = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +351,8 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Elliptical Marquee as it was: every pixel tested and written one at a time.
|
||||
/// The Elliptical Marquee one pixel at a time: every pixel tested and written on its own (the
|
||||
/// anti-aliased ramp through [`Ellipse::ramp`]).
|
||||
fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface {
|
||||
let cut = r.intersect(&area);
|
||||
let mut shape = Surface::new(PixelFormat::GRAY8);
|
||||
@@ -318,6 +368,8 @@ mod tests {
|
||||
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
|
||||
let cov = if !aa {
|
||||
if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 }
|
||||
} else if r.width().min(r.height()) > SMALL {
|
||||
Ellipse::new(r).ramp(x, y) as f32
|
||||
} else if corners == 4 {
|
||||
1.0
|
||||
} else {
|
||||
|
||||
@@ -424,6 +424,8 @@ impl Headless {
|
||||
if let Some(st) = this.session.active_mut() {
|
||||
st.saved_to(target.to_string_lossy().into_owned());
|
||||
}
|
||||
// History Options › Automatically Create New Snapshot When Saving (#2966).
|
||||
photocraft_engine::history_options_cmds::snapshot_on_save(&mut this.session);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -762,6 +762,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
| "layer.combineShapes.excludeOverlappingShapes"
|
||||
| "layer.combineShapes.mergeShapeComponents"
|
||||
| "layer.new.frame"
|
||||
// `"stepName":"Transform Path"` names the History step, not a host file.
|
||||
| "edit.transform"
|
||||
) || id.starts_with("layer.newFillLayer.")
|
||||
|| id.starts_with("layer.newAdjustmentLayer.")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! History Options › Automatically Create New Snapshot When Saving (#2966) through the headless
|
||||
//! server: a save of the document to its own layered file adds a snapshot when the option is on;
|
||||
//! a flat export is a copy and adds none, and with the option off no save does.
|
||||
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp() -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pc-save-snapshot-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn snapshots(h: &Headless) -> usize {
|
||||
h.session.active().unwrap().snapshots.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_save_adds_a_snapshot_only_with_the_option() {
|
||||
let dir = tmp();
|
||||
let psd = dir.join("a.psd");
|
||||
let png = dir.join("a.png");
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
assert_eq!(snapshots(&h), 1, "the first snapshot");
|
||||
|
||||
// Off (the default): saving adds nothing.
|
||||
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(snapshots(&h), 1);
|
||||
|
||||
h.handle("engine.execute", json!({"command": "history.options", "params": {"automaticallyCreateNewSnapshotWhenSaving": true}})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap();
|
||||
h.handle("doc.save", json!({})).unwrap();
|
||||
assert_eq!(snapshots(&h), 2, "the save added one");
|
||||
let st = h.session.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.snapshots[1].doc, &st.doc), "of the document as saved");
|
||||
assert!(!st.is_dirty(), "and the document stays saved");
|
||||
|
||||
// A flat export is a copy, not a save of the document.
|
||||
h.handle("doc.save", json!({"path": png.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(snapshots(&h), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -46,6 +46,9 @@ semver = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "3"
|
||||
# `clock`: the local time that names History Options' snapshot on save (#2966); already in the
|
||||
# tree through rmcp. The web build has no local clock here and names it "Snapshot N".
|
||||
chrono = { workspace = true, features = ["clock"] }
|
||||
|
||||
# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a
|
||||
# file is that file (std's `MetadataExt::file_index` is unstable).
|
||||
|
||||
@@ -188,6 +188,8 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
||||
// Color Sampler points are view state a step may move (crop, rotation): roll them back too, so
|
||||
// a failed second step can't leave them pinned to pixels the restored document no longer has.
|
||||
let samplers = st.color_samplers.clone();
|
||||
// So is the painting symmetry, which a rotation moves with the pixels.
|
||||
let symmetry = st.symmetry.clone();
|
||||
let r = f(s);
|
||||
if r.is_err() {
|
||||
s.journal.truncate(journal);
|
||||
@@ -212,6 +214,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
||||
st.channel_view = channel_view;
|
||||
st.coalesce = coalesce;
|
||||
st.color_samplers = samplers;
|
||||
st.symmetry = symmetry;
|
||||
st.revision += 1;
|
||||
// A failed compound does not make an otherwise untouched document dirty.
|
||||
if was_clean {
|
||||
|
||||
@@ -289,10 +289,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let background = background_param(p, cmd)?.unwrap_or_default();
|
||||
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let take_background = match p.get("takeBackground") {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "`takeBackground` must be true or false"))?,
|
||||
};
|
||||
let id = s.edit("New Artboard", |doc, active| {
|
||||
let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), vec![]);
|
||||
set_artboard(&mut g, Artboard { rect, background, preset });
|
||||
// Only the first board takes the Background in (decided before the board exists).
|
||||
let take = take_background && doc.artboards().is_empty() && doc.background().is_some();
|
||||
let id = doc.insert_above(None, g);
|
||||
if take {
|
||||
move_background_into(doc, id);
|
||||
}
|
||||
fit_canvas(doc);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
@@ -301,6 +310,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
|
||||
}
|
||||
|
||||
/// Turns the Background into a normal layer ("Layer 0") and makes it the child of board `board`,
|
||||
/// as the reference does when the first artboard is drawn. Does nothing without a Background.
|
||||
fn move_background_into(doc: &mut Document, board: LayerId) {
|
||||
let Some(bg) = doc.background().map(|l| l.id) else { return };
|
||||
let Some(mut layer) = doc.remove(bg) else { return };
|
||||
crate::extra_cmds::unlock_background(&mut layer);
|
||||
match doc.layer_mut(board).and_then(Layer::children_mut) {
|
||||
Some(children) => children.push(layer),
|
||||
// Unreachable for a group; put the layer back rather than lose it.
|
||||
None => doc.layers.insert(0, layer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata.
|
||||
fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "layer.new.artboardFromGroup";
|
||||
@@ -705,7 +727,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"layer.new.artboard",
|
||||
"Artboard…",
|
||||
&["Layer", "New"],
|
||||
r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom)} → {layer, rect}"##,
|
||||
r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom),"takeBackground":bool=false (the first board in the document also takes in the Background as a normal layer "Layer 0"; the Artboard tool's drag sets it)} → {layer, rect}"##,
|
||||
has_doc,
|
||||
new_artboard
|
||||
),
|
||||
@@ -768,6 +790,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "artboard_cmds/first_board_background_tests.rs"]
|
||||
mod first_board_background_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "artboard_cmds/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The first drawn artboard takes the Background in as "Layer 0" (#11387): `takeBackground` on
|
||||
//! `layer.new.artboard`, at 8, 16 and 32 bit, with the cases that keep today's result.
|
||||
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// 800 x 600 with the white Background and a pixel layer "Red" at 100,100 to 300,250.
|
||||
fn session(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 800, "height": 600, "depth": depth, "background": "white"})).unwrap();
|
||||
s.edit("Red", |doc, active| {
|
||||
let fmt = doc.pixel_format();
|
||||
let mut l = Layer::raster("Red", fmt);
|
||||
l.surface_mut().unwrap().fill_rect(Rect { x0: 100, y0: 100, x1: 300, y1: 250 }, &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
let id = doc.insert_above(None, l);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
fn names(layers: &[Layer]) -> Vec<&str> {
|
||||
layers.iter().map(|l| l.name.as_str()).collect()
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> Vec<String> {
|
||||
s.active().unwrap().history.entries()
|
||||
}
|
||||
|
||||
fn draw(s: &mut Session, take: serde_json::Value) -> Result<Value> {
|
||||
s.execute("layer.new.artboard", json!({"rect": [400, 300, 200, 200], "takeBackground": take}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_board_takes_the_background_in_as_layer_0() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
let before = steps(&s).len();
|
||||
let r = draw(&mut s, json!(true)).unwrap();
|
||||
let board = LayerId(r["layer"].as_u64().unwrap());
|
||||
let d = doc(&s);
|
||||
assert_eq!(names(&d.layers), ["Red", "Artboard 1"], "{depth} bit");
|
||||
assert!(d.background().is_none(), "{depth} bit");
|
||||
let b = d.layer(board).unwrap();
|
||||
assert_eq!(r["rect"], json!([400, 300, 200, 200]));
|
||||
let kids = b.children().unwrap();
|
||||
assert_eq!(names(kids), ["Layer 0"], "{depth} bit");
|
||||
let l0 = kids.first().unwrap();
|
||||
assert!(!l0.locks.transparency && !l0.locks.position, "{depth} bit");
|
||||
// The Background's own pixels travel with it: white at both samples, and Red still red.
|
||||
assert_eq!(l0.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit");
|
||||
assert_eq!(l0.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit");
|
||||
let red = d.layers.first().unwrap();
|
||||
assert_eq!(red.surface().unwrap().rgba(150, 150), [1.0, 0.0, 0.0, 1.0], "{depth} bit");
|
||||
assert_eq!((d.size.width, d.size.height), (800, 600), "{depth} bit");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(board));
|
||||
let after = steps(&s);
|
||||
assert_eq!(after.len(), before + 1, "one history step");
|
||||
assert_eq!(after.last().map(String::as_str), Some("New Artboard"));
|
||||
// One undo brings the Background back at the bottom with its name and locks.
|
||||
s.undo();
|
||||
let d = doc(&s);
|
||||
assert_eq!(names(&d.layers), ["Background", "Red"], "{depth} bit");
|
||||
let bg = d.background().unwrap();
|
||||
assert!(bg.locks.transparency && bg.locks.position, "{depth} bit");
|
||||
assert_eq!(bg.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit");
|
||||
assert_eq!(bg.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit");
|
||||
assert!(d.artboards().is_empty());
|
||||
assert_eq!((d.size.width, d.size.height), (800, 600));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_param_or_with_false_the_background_stays_outside() {
|
||||
for p in [json!({"rect": [400, 300, 200, 200]}), json!({"rect": [400, 300, 200, 200], "takeBackground": false})] {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.new.artboard", p).unwrap();
|
||||
assert_eq!(names(&doc(&s).layers), ["Background", "Red", "Artboard 1"]);
|
||||
assert!(doc(&s).background().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_board_already_present_the_result_is_as_today() {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap();
|
||||
draw(&mut s, json!(true)).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!(names(&d.layers), ["Background", "Red", "Artboard 1", "Artboard 2"]);
|
||||
assert!(d.background().is_some());
|
||||
assert!(d.artboards().iter().all(|b| b.1 != "Layer 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_background_the_result_is_as_today() {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||||
assert!(doc(&s).background().is_none());
|
||||
draw(&mut s, json!(true)).unwrap();
|
||||
assert_eq!(names(&doc(&s).layers), ["Layer 0", "Red", "Artboard 1"]);
|
||||
assert!(doc(&s).layers.last().unwrap().children().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_boolean_take_background_is_an_error_and_changes_nothing() {
|
||||
for bad in [json!("true"), json!(1), json!([true])] {
|
||||
let mut s = session(8);
|
||||
let (n, count) = (steps(&s).len(), doc(&s).layers.len());
|
||||
assert!(draw(&mut s, bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(steps(&s).len(), n, "{bad}");
|
||||
assert_eq!(doc(&s).layers.len(), count, "{bad}");
|
||||
assert!(doc(&s).background().is_some(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_param_is_listed_in_the_command_params_doc() {
|
||||
let spec = specs().into_iter().find(|c| c.id == "layer.new.artboard").unwrap();
|
||||
assert!(spec.params.contains("takeBackground"));
|
||||
}
|
||||
@@ -48,13 +48,16 @@ mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_lookup;
|
||||
mod steps_make;
|
||||
mod steps_perspective_warp;
|
||||
mod steps_type_path;
|
||||
|
||||
mod steps_other;
|
||||
mod steps_render;
|
||||
mod steps_selection;
|
||||
mod steps_shape_blur;
|
||||
mod steps_stack;
|
||||
mod steps_stylize;
|
||||
mod steps_video;
|
||||
mod tables;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -77,6 +77,8 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve
|
||||
let (new_steps, enabled) = match mapped {
|
||||
Mapped::Steps(mut s) => {
|
||||
super::steps_gallery::remember_event(item, &mut s);
|
||||
super::steps_stack::remember_item(item, &mut s);
|
||||
super::steps_video::remember(item, &mut s);
|
||||
(s, item.enabled)
|
||||
}
|
||||
Mapped::Unsupported { harmless } => {
|
||||
@@ -165,7 +167,9 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem
|
||||
return Some(AtnItem { event, dictionary: name, descriptor: Some(descriptor), ..Default::default() });
|
||||
}
|
||||
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() })
|
||||
let omit_descriptor = super::steps_stack::without_descriptor(id, ¶ms) || super::steps_video::omitted(id, ¶ms);
|
||||
let descriptor = (!omit_descriptor).then_some(descriptor);
|
||||
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Photoshop applies only the rename when a Set mixes it with opacity or blend mode (#8304).
|
||||
|
||||
@@ -786,6 +786,30 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
|
||||
target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"),
|
||||
target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"),
|
||||
Event {
|
||||
code: "",
|
||||
name: "insertBlankFrame",
|
||||
label: "Insert Blank Frame",
|
||||
ids: &["layer.videoLayers.insertBlankFrame"],
|
||||
from: |d| super::steps_video::from(d, "layer.videoLayers.insertBlankFrame"),
|
||||
to: |_, p| super::steps_video::to(p),
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "duplicateFrame",
|
||||
label: "Duplicate Frame",
|
||||
ids: &["layer.videoLayers.duplicateFrame"],
|
||||
from: |d| super::steps_video::from(d, "layer.videoLayers.duplicateFrame"),
|
||||
to: |_, p| super::steps_video::to(p),
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "deleteFrame",
|
||||
label: "Delete Frame",
|
||||
ids: &["layer.videoLayers.deleteFrame"],
|
||||
from: |d| super::steps_video::from(d, "layer.videoLayers.deleteFrame"),
|
||||
to: |_, p| super::steps_video::to(p),
|
||||
},
|
||||
target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"),
|
||||
target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"),
|
||||
target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"),
|
||||
@@ -931,6 +955,22 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]),
|
||||
ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]),
|
||||
ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]),
|
||||
Event {
|
||||
code: "",
|
||||
name: "applyImageStackPluginRenderer",
|
||||
label: "Stack Mode",
|
||||
ids: super::steps_stack::MODE_IDS,
|
||||
from: super::steps_stack::mode_from,
|
||||
to: super::steps_stack::mode_to,
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "imageStackConvertSmartObject",
|
||||
label: "Stack Mode: None",
|
||||
ids: &[super::steps_stack::NONE],
|
||||
from: super::steps_stack::none_from,
|
||||
to: super::steps_stack::none_to,
|
||||
},
|
||||
target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"),
|
||||
target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"),
|
||||
target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"),
|
||||
@@ -1068,6 +1108,14 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]),
|
||||
Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to },
|
||||
// Edit.
|
||||
Event {
|
||||
code: "",
|
||||
name: "perspectiveWarpTransform",
|
||||
label: "Perspective Warp",
|
||||
ids: &[crate::distort_cmds::PERSPECTIVE],
|
||||
from: super::steps_perspective_warp::from,
|
||||
to: super::steps_perspective_warp::to,
|
||||
},
|
||||
Event {
|
||||
code: "Grdn",
|
||||
name: "gradientClassEvent",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Recorded Perspective Warp layout and warp planes (#10710).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use photocraft_algo::perspective::{Plane, linked_corners};
|
||||
use photocraft_algo::transform::{WARP_REACH, convex_quad};
|
||||
use photocraft_io::smart_map::{MAX_PERSPECTIVE_WARP_PLANES as MAX_PLANES, perspective_warp_geometry, perspective_warp_indexed_geometry};
|
||||
|
||||
use super::convert::normalise;
|
||||
use super::descriptors::{desc, enm, get_obj, get_ref, layer_ref, layer_target, px, ref_class};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, ReferenceItem, Value, json};
|
||||
use crate::distort_cmds::PERSPECTIVE;
|
||||
|
||||
const EVENT: &str = "perspectiveWarpTransform";
|
||||
const SOURCE: &str = "__atnPerspectiveWarp";
|
||||
const RECT: &str = "__atnPerspectiveWarpRect";
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// Allows exactly the measured keys, once each. The preserved descriptor retains their order.
|
||||
fn exact_keys(d: &Descriptor, keys: &[&str]) -> bool {
|
||||
d.name.0.len() <= 1024 && d.items.len() == keys.len() && keys.iter().all(|key| d.items.iter().filter(|(id, _)| id.is(key)).count() == 1)
|
||||
}
|
||||
|
||||
fn coordinate(value: f64) -> bool {
|
||||
value.abs() <= f64::from(WARP_REACH)
|
||||
}
|
||||
|
||||
fn rect_valid([left, top, right, bottom]: [f64; 4]) -> bool {
|
||||
[left, top, right, bottom].into_iter().all(coordinate) && left < right && top < bottom
|
||||
}
|
||||
|
||||
fn reference_rect(d: &Descriptor) -> Option<[f64; 4]> {
|
||||
let r = get_obj(d, "referenceRect")?;
|
||||
if !r.class_id.is("Rctn") || !exact_keys(r, &["Top ", "Left", "Btom", "Rght"]) {
|
||||
return None;
|
||||
}
|
||||
let number = |key| match r.get(key)? {
|
||||
D::UnitFloat { unit: [b'#', b'P', b'x', b'l'], value } => Some(*value),
|
||||
_ => None,
|
||||
};
|
||||
let r = [number("Left")?, number("Top ")?, number("Rght")?, number("Btom")?];
|
||||
rect_valid(r).then_some(r)
|
||||
}
|
||||
|
||||
fn objects<'a>(d: &'a Descriptor, key: &str, class: &str, keys: &[&str], max: usize) -> Option<&'a [D]> {
|
||||
let D::List(items) = d.get(key)? else { return None };
|
||||
(!items.is_empty() && items.len() <= max && items.iter().all(|item| matches!(item, D::Descriptor(d) if d.class_id.is(class) && exact_keys(d, keys))))
|
||||
.then_some(items)
|
||||
}
|
||||
|
||||
/// Each recorded index must link exactly the corners the native command would link. Edges
|
||||
/// shared by two faces must have opposite winding, since the native map has one neighbour per edge.
|
||||
fn same_topology(planes: &[Plane], indices: &[[usize; 4]], vertices: usize) -> Option<()> {
|
||||
let at = |plane: usize, corner: usize| indices.get(plane)?.get(corner);
|
||||
let mut linked = BTreeSet::new();
|
||||
for group in linked_corners(planes) {
|
||||
let &(plane, corner) = group.first()?;
|
||||
let index = at(plane, corner)?;
|
||||
if !linked.insert(*index) || !group.iter().all(|&(p, c)| at(p, c) == Some(index)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut edges = BTreeSet::new();
|
||||
let distinct = indices.iter().all(|&[a, b, c, d]| [(a, b), (b, c), (c, d), (d, a)].into_iter().all(|edge| edges.insert(edge)));
|
||||
(linked.len() == vertices && distinct).then_some(())
|
||||
}
|
||||
|
||||
/// Decodes the recorded target and its document-space planes, retaining the reference rectangle
|
||||
/// for export. Unmodelled references and geometry stay as kept steps.
|
||||
fn parameters(d: &Descriptor) -> Option<Value> {
|
||||
if !(d.class_id.is(EVENT) || d.class_id.is("null")) || !exact_keys(d, &["null", "Md ", "referenceRect", "vertices", "warpedVertices", "quads"]) {
|
||||
return None;
|
||||
}
|
||||
if !matches!(d.get("Md ")?, D::Enumerated { type_id, value } if type_id.is("perspectiveWarpMode") && value.is("warp")) {
|
||||
return None;
|
||||
}
|
||||
let target = get_ref(d, "null")?;
|
||||
if target.len() != 1 || ref_class(target).as_deref() != Some("Lyr") {
|
||||
return None;
|
||||
}
|
||||
match target.first()? {
|
||||
ReferenceItem::Enumerated { type_id, value, .. } if type_id.is("Ordn") && value.is("Trgt") => {}
|
||||
ReferenceItem::Name { name, .. } if name.0.len() <= 1024 && String::from_utf16(&name.0).is_ok() && !name.to_string_lossy().is_empty() => {}
|
||||
ReferenceItem::Property { key, .. } if key.is("Bckg") => {}
|
||||
_ => return None,
|
||||
}
|
||||
let mut p = layer_target(target)?;
|
||||
let rect = reference_rect(d)?;
|
||||
let src = objects(d, "vertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?;
|
||||
objects(d, "warpedVertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?;
|
||||
objects(d, "quads", "perspectiveWarpQuad", &["indices"], MAX_PLANES)?;
|
||||
let (planes, indices) = perspective_warp_indexed_geometry(d)?;
|
||||
if !planes.iter().all(|p| p.src.iter().chain(&p.dst).flatten().copied().all(coordinate) && convex_quad(&p.src) && convex_quad(&p.dst)) {
|
||||
return None;
|
||||
}
|
||||
same_topology(&planes, &indices, src.len())?;
|
||||
p.insert("planes".into(), json!(planes));
|
||||
p.insert("target".into(), json!("pixels"));
|
||||
p.insert(RECT.into(), json!(rect));
|
||||
Some(Value::Object(p))
|
||||
}
|
||||
|
||||
/// Keep the exact recorded descriptor after decoding its executable settings.
|
||||
pub(super) fn from(d: &Descriptor) -> Mapped {
|
||||
let mapped = || {
|
||||
let mut p = parameters(d)?;
|
||||
let bytes = d.to_bytes();
|
||||
if bytes.len() > MAX_DESCRIPTOR_BYTES {
|
||||
return None;
|
||||
}
|
||||
p[SOURCE] = json!(crate::paint::tile::b64_encode(&bytes));
|
||||
Some(p)
|
||||
};
|
||||
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step(PERSPECTIVE, p))
|
||||
}
|
||||
|
||||
/// Emits the measured field order when the caller supplies the original reference rectangle.
|
||||
fn canonical(p: &Value) -> Option<Descriptor> {
|
||||
let [left, top, right, bottom] = serde_json::from_value(p.get(RECT)?.clone()).ok()?;
|
||||
if !rect_valid([left, top, right, bottom]) {
|
||||
return None;
|
||||
}
|
||||
let planes = p.get("planes")?.as_array().filter(|planes| (1..=MAX_PLANES).contains(&planes.len()))?;
|
||||
let planes: Vec<Plane> = serde_json::from_value(Value::Array(planes.clone())).ok()?;
|
||||
let rect = desc("Rctn").with("Top ", px(top)).with("Left", px(left)).with("Btom", px(bottom)).with("Rght", px(right));
|
||||
let (src, dst, quads) = perspective_warp_geometry(&planes);
|
||||
Some(
|
||||
desc(EVENT)
|
||||
.with("null", layer_ref(p)?)
|
||||
.with("Md ", enm("perspectiveWarpMode", "warp"))
|
||||
.with("referenceRect", D::Descriptor(rect))
|
||||
.with("vertices", src)
|
||||
.with("warpedVertices", dst)
|
||||
.with("quads", quads),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reuses source bytes only while they still describe the same target, rectangle and planes.
|
||||
/// A changed recorded mesh cannot silently replace its indexed topology.
|
||||
pub(super) fn to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) || !p.as_object()?.keys().all(|k| ["layer", "planes", "target", "interpolation", RECT, SOURCE].contains(&k.as_str())) {
|
||||
return None;
|
||||
}
|
||||
if p.get("interpolation").is_some_and(|v| v.as_str() != Some("bicubic")) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = if let Some(source) = p.get(SOURCE) {
|
||||
let text = source.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR_BYTES * 2)?;
|
||||
let bytes = crate::paint::tile::b64_decode(text)?;
|
||||
if bytes.len() > MAX_DESCRIPTOR_BYTES {
|
||||
return None;
|
||||
}
|
||||
Descriptor::from_bytes(&bytes).ok()?
|
||||
} else {
|
||||
canonical(p)?
|
||||
};
|
||||
let mut expected = parameters(&descriptor)?;
|
||||
let mut actual = p.clone();
|
||||
let m = actual.as_object_mut()?;
|
||||
m.remove(SOURCE);
|
||||
m.remove("interpolation");
|
||||
if m.get("layer").is_some_and(Value::is_null) {
|
||||
m.remove("layer");
|
||||
}
|
||||
m.insert("target".into(), json!("pixels"));
|
||||
normalise(&mut actual);
|
||||
normalise(&mut expected);
|
||||
(actual == expected).then_some(descriptor)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Recorded Smart Object Stack Modes use the existing native mode commands.
|
||||
|
||||
use photocraft_doc::StackMode;
|
||||
|
||||
use super::descriptors::{class, desc, jstr, text};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{AtnItem, D, Descriptor, Value, json};
|
||||
|
||||
const PREFIX: &str = "layer.smartObjects.stackMode.";
|
||||
const SOURCE: &str = "_atnStackDescriptor";
|
||||
pub(super) const NONE: &str = "layer.smartObjects.stackMode.none";
|
||||
|
||||
pub(super) const MODE_IDS: &[&str] = &[
|
||||
"layer.smartObjects.stackMode.range",
|
||||
"layer.smartObjects.stackMode.entropy",
|
||||
"layer.smartObjects.stackMode.kurtosis",
|
||||
"layer.smartObjects.stackMode.maximum",
|
||||
"layer.smartObjects.stackMode.median",
|
||||
"layer.smartObjects.stackMode.minimum",
|
||||
"layer.smartObjects.stackMode.mean",
|
||||
"layer.smartObjects.stackMode.skewness",
|
||||
"layer.smartObjects.stackMode.standardDeviation",
|
||||
"layer.smartObjects.stackMode.summation",
|
||||
"layer.smartObjects.stackMode.variance",
|
||||
];
|
||||
|
||||
const MODES: &[(&str, StackMode)] = &[
|
||||
("rang", StackMode::Range),
|
||||
("entr", StackMode::Entropy),
|
||||
("kurt", StackMode::Kurtosis),
|
||||
("maxx", StackMode::Maximum),
|
||||
("medn", StackMode::Median),
|
||||
("minn", StackMode::Minimum),
|
||||
("avrg", StackMode::Mean),
|
||||
("skew", StackMode::Skewness),
|
||||
("stdv", StackMode::StandardDeviation),
|
||||
("summ", StackMode::Summation),
|
||||
("vari", StackMode::Variance),
|
||||
];
|
||||
|
||||
/// The plug-in class selects the mode; its localized display name does not.
|
||||
fn recorded_mode(d: &Descriptor) -> Option<(&'static str, StackMode)> {
|
||||
if d.items.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let (Some(D::Class(plugin)), Some(D::Text(_))) = (d.get("imageStackPlugin"), d.get("Nm ")) else { return None };
|
||||
MODES.iter().copied().find(|(code, _)| plugin.class_id.is(code))
|
||||
}
|
||||
|
||||
fn descriptor(code: &str, mode: StackMode) -> Descriptor {
|
||||
desc("null").with("imageStackPlugin", D::Class(class(code))).with("Nm ", text(mode.label()))
|
||||
}
|
||||
|
||||
/// Keep descriptor names, key order, class encodings and original UTF-16 text on unchanged saves.
|
||||
fn params(d: &Descriptor, canonical: &Descriptor) -> Value {
|
||||
let mut p = json!({});
|
||||
if d != canonical {
|
||||
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn original(p: &Value) -> Option<Descriptor> {
|
||||
Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()
|
||||
}
|
||||
|
||||
/// The descriptorless form reaches the mapper as a default empty `null` descriptor.
|
||||
fn is_none_descriptor(d: &Descriptor) -> bool {
|
||||
d.items.is_empty() && d.class_id.is("null")
|
||||
}
|
||||
|
||||
/// Unknown mode classes, types, duplicate keys and extra settings stay complete kept steps.
|
||||
pub(super) fn mode_from(d: &Descriptor) -> Mapped {
|
||||
let Some((code, mode)) = recorded_mode(d) else { return Mapped::Unsupported { harmless: false } };
|
||||
step(&format!("{PREFIX}{}", mode.id()), params(d, &descriptor(code, mode)))
|
||||
}
|
||||
|
||||
/// The measured form targets the active Smart Object; explicit native targets refuse export.
|
||||
pub(super) fn mode_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if p.as_object()?.keys().any(|key| key != SOURCE) {
|
||||
return None;
|
||||
}
|
||||
let suffix = id.strip_prefix(PREFIX)?;
|
||||
let (code, mode) = MODES.iter().copied().find(|(_, mode)| suffix == mode.id())?;
|
||||
let canonical = descriptor(code, mode);
|
||||
if p.get(SOURCE).is_none() {
|
||||
return Some(canonical);
|
||||
}
|
||||
let source = original(p)?;
|
||||
if is_none_descriptor(&source) {
|
||||
return Some(canonical);
|
||||
}
|
||||
let (_, recorded) = recorded_mode(&source)?;
|
||||
Some(if recorded == mode { source } else { canonical })
|
||||
}
|
||||
|
||||
/// None has no recorded settings and removes the native Smart Object stack mode.
|
||||
pub(super) fn none_from(d: &Descriptor) -> Mapped {
|
||||
if is_none_descriptor(d) { step(NONE, json!({})) } else { Mapped::Unsupported { harmless: false } }
|
||||
}
|
||||
|
||||
pub(super) fn none_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if p.as_object()?.keys().any(|key| key != SOURCE) {
|
||||
return None;
|
||||
}
|
||||
if p.get(SOURCE).is_none() {
|
||||
return Some(desc("null"));
|
||||
}
|
||||
let source = original(p)?;
|
||||
if is_none_descriptor(&source) {
|
||||
return Some(source);
|
||||
}
|
||||
recorded_mode(&source).map(|_| desc("null"))
|
||||
}
|
||||
|
||||
/// Import otherwise presents an absent descriptor as an empty one. Retain the distinction for None.
|
||||
pub(super) fn remember_item(item: &AtnItem, steps: &mut [(String, Value)]) {
|
||||
if let [(id, Value::Object(p))] = steps
|
||||
&& id.as_str() == NONE
|
||||
&& let Some(d) = &item.descriptor
|
||||
{
|
||||
p.insert(SOURCE.into(), photocraft_paint::tile::b64_encode(&d.to_bytes()).into());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn without_descriptor(id: &str, p: &Value) -> bool {
|
||||
id == NONE && p.get(SOURCE).is_none()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! The three measured, option-free edits of the active video layer's current frame.
|
||||
use super::{
|
||||
AtnItem, Descriptor, Value,
|
||||
fields::{Mapped, step},
|
||||
json,
|
||||
};
|
||||
|
||||
const NO_DESCRIPTOR: &str = "_atnVideoFrameNoDescriptor";
|
||||
|
||||
/// These events edit video footage, separately from document frame animation.
|
||||
fn frame_command(id: &str) -> bool {
|
||||
matches!(id, "layer.videoLayers.insertBlankFrame" | "layer.videoLayers.duplicateFrame" | "layer.videoLayers.deleteFrame")
|
||||
}
|
||||
|
||||
/// Only the measured empty descriptor is executable; extra settings remain kept.
|
||||
pub(super) fn from(d: &Descriptor, id: &str) -> Mapped {
|
||||
if *d == Descriptor::default() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } }
|
||||
}
|
||||
|
||||
/// Import's absent-descriptor placeholder must not become a descriptor on save.
|
||||
pub(super) fn remember(item: &AtnItem, steps: &mut [(String, Value)]) {
|
||||
if item.descriptor.is_none() {
|
||||
for (id, params) in steps {
|
||||
if frame_command(id)
|
||||
&& let Some(params) = params.as_object_mut()
|
||||
{
|
||||
params.insert(NO_DESCRIPTOR.into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse native options and malformed transport data that have no wire field.
|
||||
pub(super) fn to(p: &Value) -> Option<Descriptor> {
|
||||
p.as_object()?.iter().all(|(key, value)| key == NO_DESCRIPTOR && value == &Value::Bool(true)).then(Descriptor::default)
|
||||
}
|
||||
|
||||
/// Called only after `to` has validated the complete native parameter object.
|
||||
pub(super) fn omitted(id: &str, p: &Value) -> bool {
|
||||
frame_command(id) && p.get(NO_DESCRIPTOR) == Some(&Value::Bool(true))
|
||||
}
|
||||
@@ -658,6 +658,11 @@ fn settings_at_the_value_the_event_plays_still_export() {
|
||||
/// Each mapped command retains its parameters through its corresponding ATN descriptor.
|
||||
fn every_mapped_command_exports_and_imports_back() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
(
|
||||
"edit.perspectiveWarp",
|
||||
json!({"planes":[{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5,5],[17,4],[18,20],[4,19]]}],
|
||||
"target":"pixels","__atnPerspectiveWarpRect":[0,0,32,24]}),
|
||||
),
|
||||
(
|
||||
"paint.gradient",
|
||||
json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100,
|
||||
@@ -680,6 +685,18 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
),
|
||||
("edit.fade", json!({"opacity":50,"mode":"Multiply"})),
|
||||
("layer.linkLayers", json!({})),
|
||||
("layer.smartObjects.stackMode.range", json!({})),
|
||||
("layer.smartObjects.stackMode.entropy", json!({})),
|
||||
("layer.smartObjects.stackMode.kurtosis", json!({})),
|
||||
("layer.smartObjects.stackMode.maximum", json!({})),
|
||||
("layer.smartObjects.stackMode.median", json!({})),
|
||||
("layer.smartObjects.stackMode.minimum", json!({})),
|
||||
("layer.smartObjects.stackMode.mean", json!({})),
|
||||
("layer.smartObjects.stackMode.skewness", json!({})),
|
||||
("layer.smartObjects.stackMode.standardDeviation", json!({})),
|
||||
("layer.smartObjects.stackMode.summation", json!({})),
|
||||
("layer.smartObjects.stackMode.variance", json!({})),
|
||||
("layer.smartObjects.stackMode.none", json!({})),
|
||||
("layer.distribute.leftEdges", json!({})),
|
||||
("layer.distribute.horizontalCenters", json!({})),
|
||||
("layer.distribute.rightEdges", json!({})),
|
||||
@@ -826,6 +843,9 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("type.loadDefaultTypeStyles", json!({})),
|
||||
("type.saveDefaultTypeStyles", json!({})),
|
||||
("image.analysis.recordMeasurements", json!({})),
|
||||
("layer.videoLayers.insertBlankFrame", json!({})),
|
||||
("layer.videoLayers.duplicateFrame", json!({})),
|
||||
("layer.videoLayers.deleteFrame", json!({})),
|
||||
("file.automate.cropAndStraightenPhotos", json!({})),
|
||||
("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})),
|
||||
("layer.new.artboardFromGroup", json!({})),
|
||||
@@ -1054,7 +1074,9 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
continue;
|
||||
}
|
||||
let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id()));
|
||||
match (e.from)(item.descriptor.as_ref().unwrap()) {
|
||||
assert_eq!(item.descriptor.is_none(), *id == super::steps_stack::NONE, "{id}: unexpected descriptor presence");
|
||||
let descriptor = item.descriptor.unwrap_or_default();
|
||||
match (e.from)(&descriptor) {
|
||||
Mapped::Steps(steps) => {
|
||||
assert_eq!(steps.len(), 1, "{id}: {steps:?}");
|
||||
let navigation = match *id {
|
||||
@@ -1080,6 +1102,9 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
// Executable values match; independent public tests verify the source carrier.
|
||||
back.as_object_mut().unwrap().remove("__atnClassicGradient");
|
||||
}
|
||||
if *id == "edit.perspectiveWarp" {
|
||||
back.as_object_mut().unwrap().remove("__atnPerspectiveWarp");
|
||||
}
|
||||
normalise(&mut back);
|
||||
if *id == "actions.play" || *id == "actions.conditional" {
|
||||
back["set"] = json!("S");
|
||||
|
||||
@@ -54,9 +54,10 @@ fn can_split(s: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Merge Channels needs a document per colour channel: three at least (RGB and Lab; CMYK four).
|
||||
fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) }
|
||||
if mergeable(s).len() >= 3 { Ok(()) } else { Err("Merge Channels needs at least three open grayscale documents of the same size".into()) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1238,3 +1238,21 @@ fn split_refuses_an_image_with_more_than_one_layer() {
|
||||
assert!(s.execute("channel.split", json!({})).is_err());
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
}
|
||||
|
||||
/// Merge Channels is offered from three grayscale documents of one size, the fewest a mode
|
||||
/// (RGB or Lab) merges; two never merge (#11451).
|
||||
#[test]
|
||||
fn merge_channels_waits_for_three_grayscale_documents() {
|
||||
let mut s = Session::new();
|
||||
for _ in 0..2 {
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
}
|
||||
s.execute("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
s.set_active(0);
|
||||
let why = s.disabled_reason("channel.merge").expect("two of one size are not enough");
|
||||
assert!(why.contains("three"), "{why}");
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
assert!(s.is_enabled("channel.merge"));
|
||||
s.execute("channel.merge", json!({"mode": "lab"})).unwrap();
|
||||
assert_eq!(s.documents().len(), 2, "the three of one size merged; the other is left");
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec {
|
||||
id: PUPPET_SMART,
|
||||
label: "Puppet Warp",
|
||||
menu: &["Layer", "Smart Objects"],
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: P,
|
||||
enabled: smart_layer,
|
||||
@@ -427,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec {
|
||||
id: PERSPECTIVE_SMART,
|
||||
label: "Perspective Warp",
|
||||
menu: &["Layer", "Smart Objects"],
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: Q,
|
||||
enabled: smart_layer,
|
||||
|
||||
@@ -279,7 +279,7 @@ fn content_aware_native_with_options(
|
||||
let window = sampling_window(hole_bounds, canvas);
|
||||
let n = surf.format().channels();
|
||||
let (w, h) = window_size(window)?;
|
||||
let img = surf.read_region(window);
|
||||
let img = crate::allocation::read_region(surf, window, "Content-Aware fill samples")?;
|
||||
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?;
|
||||
let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?;
|
||||
let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?;
|
||||
|
||||
@@ -243,6 +243,8 @@ pub struct Floating {
|
||||
/// The piece has been moved at least once, even if it is back where it was cut (nudged away
|
||||
/// and back): its selection is the moved pixels' then, as each of Photoshop's nudges leaves it.
|
||||
pub moved: bool,
|
||||
/// History step name when dropped; `None` is "Move". The Move tool names a ⌥-drag copy "Duplicate".
|
||||
pub step_name: Option<String>,
|
||||
}
|
||||
|
||||
/// The floating piece of `st`, if it is still valid (the document hasn't changed under it).
|
||||
@@ -289,12 +291,14 @@ pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
|
||||
fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy"));
|
||||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
// A name for the History step that drops the piece: short and not blank, else the default.
|
||||
let step_name = p.get("stepName").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty() && s.chars().count() <= 64).map(str::to_string);
|
||||
let background = s.tools.background;
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if floating(st).is_none() {
|
||||
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?;
|
||||
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false });
|
||||
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false, step_name });
|
||||
}
|
||||
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
|
||||
f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy));
|
||||
@@ -311,20 +315,21 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
||||
return Ok(json!({"dropped": false}));
|
||||
};
|
||||
st.floating = None;
|
||||
let name = f.step_name.clone().unwrap_or_else(|| "Move".to_string());
|
||||
let (dx, dy) = f.offset;
|
||||
if (dx, dy) == (0, 0) {
|
||||
// Back where it was cut: the pixels are as they were. A piece that never moved (a click)
|
||||
// changes nothing; one nudged away and back keeps the moved pixels' selection (#7538).
|
||||
if let Some(outline) = f.parts.outline().filter(|_| f.moved) {
|
||||
let outline = outline.clone();
|
||||
s.edit("Move", |doc, _| {
|
||||
s.edit(&name, |doc, _| {
|
||||
doc.selection = Some(outline);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return Ok(json!({"dropped": true, "offset": [0, 0]}));
|
||||
}
|
||||
s.edit("Move", |doc, _| {
|
||||
s.edit(&name, |doc, _| {
|
||||
f.parts.place(doc, dx, dy)?;
|
||||
let sel = f.parts.outline().or(doc.selection.as_ref());
|
||||
doc.selection = sel.map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy));
|
||||
@@ -374,7 +379,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Float Selection",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
|
||||
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"stepName":string="Move" (History name of the step that drops the piece; blank or over 64 characters falls back to Move),"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
|
||||
enabled: can_float,
|
||||
journal: true,
|
||||
run: float,
|
||||
|
||||
@@ -1,35 +1,113 @@
|
||||
//! History panel › History Options… (#2966): Allow Non-Linear History and Make Layer Visibility
|
||||
//! Changes Undoable. The values are preferences (`historyOptions.*`, also `prefs.set`), applied
|
||||
//! by [`Session::apply_prefs`] and [`Session::edit_visibility`]. Without params the command
|
||||
//! reports them; the GUI opens its dialog instead and runs it with the dialog's values on OK.
|
||||
//!
|
||||
//! Photoshop's three snapshot options (Automatically Create First Snapshot, Automatically
|
||||
//! Create New Snapshot When Saving, Show New Snapshot Dialog by Default) come with #3634, which
|
||||
//! makes the snapshots act on them.
|
||||
//! History panel › History Options… (#2966), Photoshop's five checkboxes: Automatically Create
|
||||
//! First Snapshot, Automatically Create New Snapshot When Saving, Allow Non-Linear History, Show
|
||||
//! New Snapshot Dialog by Default and Make Layer Visibility Changes Undoable. The values are
|
||||
//! preferences (`historyOptions.*`, also `prefs.set`), applied by [`Session::add_document`],
|
||||
//! [`snapshot_on_save`], [`Session::apply_prefs`], [`Session::edit_visibility`] and the GUI's
|
||||
//! camera button. Without params the command reports them; the GUI opens its dialog instead and
|
||||
//! runs it with the dialog's values on OK.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::history_cmds::Snapshot;
|
||||
use crate::prefs::HistoryOptions;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "history.options";
|
||||
|
||||
/// The option keys, as in `prefs.get historyOptions` and the command's params.
|
||||
pub const OPTIONS: [&str; 2] = ["allowNonLinearHistory", "makeLayerVisibilityChangesUndoable"];
|
||||
/// The option keys, as in `prefs.get historyOptions` and the command's params, in the order of
|
||||
/// Photoshop's dialog.
|
||||
pub const OPTIONS: [&str; 5] = [
|
||||
"automaticallyCreateFirstSnapshot",
|
||||
"automaticallyCreateNewSnapshotWhenSaving",
|
||||
"allowNonLinearHistory",
|
||||
"showNewSnapshotDialogByDefault",
|
||||
"makeLayerVisibilityChangesUndoable",
|
||||
];
|
||||
|
||||
/// The option named `key` (one of [`OPTIONS`]).
|
||||
fn option_mut<'a>(o: &'a mut HistoryOptions, key: &str) -> Option<&'a mut bool> {
|
||||
Some(match key {
|
||||
"automaticallyCreateFirstSnapshot" => &mut o.automatically_create_first_snapshot,
|
||||
"automaticallyCreateNewSnapshotWhenSaving" => &mut o.automatically_create_new_snapshot_when_saving,
|
||||
"allowNonLinearHistory" => &mut o.allow_non_linear_history,
|
||||
"showNewSnapshotDialogByDefault" => &mut o.show_new_snapshot_dialog_by_default,
|
||||
"makeLayerVisibilityChangesUndoable" => &mut o.make_layer_visibility_changes_undoable,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// The current History Options as `{allowNonLinearHistory, makeLayerVisibilityChangesUndoable}`.
|
||||
/// The current History Options, keyed by [`OPTIONS`].
|
||||
pub fn current(s: &Session) -> Value {
|
||||
let o = &s.prefs().history_options;
|
||||
serde_json::json!({
|
||||
"automaticallyCreateFirstSnapshot": o.automatically_create_first_snapshot,
|
||||
"automaticallyCreateNewSnapshotWhenSaving": o.automatically_create_new_snapshot_when_saving,
|
||||
"allowNonLinearHistory": o.allow_non_linear_history,
|
||||
"showNewSnapshotDialogByDefault": o.show_new_snapshot_dialog_by_default,
|
||||
"makeLayerVisibilityChangesUndoable": o.make_layer_visibility_changes_undoable,
|
||||
})
|
||||
}
|
||||
|
||||
/// History Options › Show New Snapshot Dialog by Default: whether a plain click on the History
|
||||
/// panel's camera button opens New Snapshot.
|
||||
pub fn show_new_snapshot_dialog(s: &Session) -> bool {
|
||||
s.prefs().history_options.show_new_snapshot_dialog_by_default
|
||||
}
|
||||
|
||||
/// History Options › Automatically Create New Snapshot When Saving: after the active document
|
||||
/// was saved to its own file (not a copy or an export), add a snapshot of it named with the local
|
||||
/// time of the save, as Photoshop does. Not a history state. Returns the new snapshot's id, or
|
||||
/// `None` when the option is off, no document is active, or the document is unchanged since the
|
||||
/// snapshot the previous save added (and that snapshot is still there), so saving a clean
|
||||
/// document again doesn't add a duplicate row. Shells call it where they record a document save.
|
||||
///
|
||||
/// Memory: like every History panel snapshot, each one pins a copy of the document for the
|
||||
/// session (only the tiles that differ from the document and the other snapshots cost memory).
|
||||
/// [`crate::DocState::trim_history`] counts those tiles against the History & Cache budget by
|
||||
/// dropping the oldest history states, but never drops a snapshot itself: many saves of a
|
||||
/// changing document can hold more than the budget until snapshots are deleted or the document
|
||||
/// is closed.
|
||||
pub fn snapshot_on_save(s: &mut Session) -> Option<u64> {
|
||||
if !s.prefs().history_options.automatically_create_new_snapshot_when_saving {
|
||||
return None;
|
||||
}
|
||||
let st = s.active_mut()?;
|
||||
// Saved again without a change: the previous save's snapshot already shows this document.
|
||||
if let Some(prev) = st.save_snapshot
|
||||
&& st.snapshots.iter().any(|snap| snap.id == prev && std::sync::Arc::ptr_eq(&snap.doc, &st.doc))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = save_snapshot_name().unwrap_or_else(|| crate::history_cmds::next_snapshot_name(st));
|
||||
let id = st.next_snapshot;
|
||||
st.next_snapshot = id.saturating_add(1);
|
||||
let (doc, layers) = (st.doc.clone(), st.layer_target());
|
||||
// Like the camera button: the document and its saved state are unchanged.
|
||||
st.snapshots.push(Snapshot { id, name, doc, layers });
|
||||
st.save_snapshot = Some(id);
|
||||
// The snapshot's tiles count against the history budget.
|
||||
st.trim_history();
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The local time as `HH:MM:SS`, the name of a snapshot made on save; `None` without a clock
|
||||
/// (the web build), where the snapshot gets the camera button's "Snapshot N" instead.
|
||||
fn save_snapshot_name() -> Option<String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Some(chrono::Local::now().format("%H:%M:%S").to_string())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given: Map<String, Value> = match p {
|
||||
Value::Null => Map::new(),
|
||||
@@ -39,7 +117,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Validate everything before changing anything.
|
||||
let mut set = Vec::new();
|
||||
for (k, v) in &given {
|
||||
if !OPTIONS.contains(&k.as_str()) {
|
||||
if option_mut(&mut HistoryOptions::default(), k).is_none() {
|
||||
return Err(bad(format!("unknown option `{k}` (expected {})", OPTIONS.join(", "))));
|
||||
}
|
||||
let b = v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false")))?;
|
||||
@@ -48,9 +126,8 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !set.is_empty() {
|
||||
s.edit_prefs(|prefs| {
|
||||
for (k, b) in &set {
|
||||
match k.as_str() {
|
||||
"allowNonLinearHistory" => prefs.history_options.allow_non_linear_history = *b,
|
||||
_ => prefs.history_options.make_layer_visibility_changes_undoable = *b,
|
||||
if let Some(o) = option_mut(&mut prefs.history_options, k) {
|
||||
*o = *b;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -120,7 +197,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "History Options…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"allowNonLinearHistory":bool?,"makeLayerVisibilityChangesUndoable":bool?} → both values (no params: report them). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##,
|
||||
params: r##"{"automaticallyCreateFirstSnapshot":bool?,"automaticallyCreateNewSnapshotWhenSaving":bool?,"allowNonLinearHistory":bool?,"showNewSnapshotDialogByDefault":bool?,"makeLayerVisibilityChangesUndoable":bool?} → all five values (no params: report them). Automatically Create First Snapshot: a new or opened document starts with a snapshot of itself, named after it (default on). Automatically Create New Snapshot When Saving: each save of the document (not a copy) adds a snapshot named with the time of the save (default off). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Show New Snapshot Dialog by Default: a click on the History panel's camera button opens New Snapshot, not only ⌥-click (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##,
|
||||
enabled: always,
|
||||
run: options,
|
||||
journal: false,
|
||||
@@ -148,10 +225,21 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_photoshop_and_round_trip_through_prefs() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap(), json!({"allowNonLinearHistory": false, "makeLayerVisibilityChangesUndoable": true}));
|
||||
let defaults = json!({
|
||||
"automaticallyCreateFirstSnapshot": true,
|
||||
"automaticallyCreateNewSnapshotWhenSaving": false,
|
||||
"allowNonLinearHistory": false,
|
||||
"showNewSnapshotDialogByDefault": false,
|
||||
"makeLayerVisibilityChangesUndoable": true,
|
||||
});
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap(), defaults);
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions"})).unwrap(), current(&s));
|
||||
let r = s.execute(CMD, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
assert_eq!(r, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false}));
|
||||
let flipped: Map<String, Value> = defaults.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!(!v.as_bool().unwrap()))).collect();
|
||||
let r = s.execute(CMD, Value::Object(flipped.clone())).unwrap();
|
||||
assert_eq!(r, Value::Object(flipped));
|
||||
for key in OPTIONS {
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": format!("historyOptions.{key}")})).unwrap(), r[key], "{key}");
|
||||
}
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions.allowNonLinearHistory"})).unwrap(), json!(true));
|
||||
// Saved and restored with the other preferences.
|
||||
let saved = s.prefs_to_json();
|
||||
@@ -177,6 +265,11 @@ mod tests {
|
||||
json!({"allowNonLinearHistory": 1}),
|
||||
json!({"makeLayerVisibilityChangesUndoable": null}),
|
||||
json!({"allowNonLinearHistory": true, "snapshots": true}),
|
||||
json!({"automaticallyCreateFirstSnapshot": "true"}),
|
||||
json!({"automaticallyCreateNewSnapshotWhenSaving": 0}),
|
||||
json!({"showNewSnapshotDialogByDefault": [true]}),
|
||||
json!({"showNewSnapshotDialogByDefault": true, "showNewSnapshotDialog": true}),
|
||||
json!({"AutomaticallyCreateFirstSnapshot": false}),
|
||||
json!([true]),
|
||||
json!("x"),
|
||||
json!(3),
|
||||
@@ -185,6 +278,8 @@ mod tests {
|
||||
assert_eq!(current(&s), before, "{p} changed nothing");
|
||||
}
|
||||
assert!(s.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": "on"})).is_err());
|
||||
assert!(s.execute("prefs.set", json!({"path": "historyOptions.automaticallyCreateFirstSnapshot", "value": 1})).is_err());
|
||||
assert!(s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": null})).is_err());
|
||||
assert_eq!(current(&s), before);
|
||||
}
|
||||
|
||||
@@ -358,6 +453,139 @@ mod tests {
|
||||
assert!(s.active().unwrap().history.non_linear);
|
||||
}
|
||||
|
||||
fn snapshot_names(s: &Session) -> Vec<String> {
|
||||
s.active().unwrap().snapshots.iter().map(|snap| snap.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// PNG bytes of a small image, to open.
|
||||
fn png() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
|
||||
crate::file_cmds::encode(&s.active().unwrap().doc, "a.png", None::<f64>).unwrap().0
|
||||
}
|
||||
|
||||
/// Automatically Create First Snapshot (checked against Photoshop): on (the default), a new or
|
||||
/// opened document starts with a snapshot named after it; off, it starts with none and the
|
||||
/// camera's first snapshot is still "Snapshot 1".
|
||||
#[test]
|
||||
fn first_snapshot_follows_the_option_on_new_and_open() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let name = s.active().unwrap().doc.name.clone();
|
||||
assert_eq!(snapshot_names(&s), [name]);
|
||||
crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap();
|
||||
assert_eq!(snapshot_names(&s), ["photo.png"], "named after the file, as Photoshop does");
|
||||
|
||||
s.execute(CMD, json!({"automaticallyCreateFirstSnapshot": false})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
assert!(snapshot_names(&s).is_empty());
|
||||
assert_eq!(entries(&s).len(), 1, "the opening state is still there");
|
||||
crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap();
|
||||
assert!(snapshot_names(&s).is_empty());
|
||||
assert_eq!(s.execute("history.newSnapshot", json!({})).unwrap(), json!({"snapshot": 1, "name": "Snapshot 1"}));
|
||||
assert_eq!(crate::inspect::document(s.active().unwrap())["snapshots"], json!([{"id": 1, "name": "Snapshot 1"}]));
|
||||
// Documents opened earlier keep theirs: the option applies when a document opens.
|
||||
s.set_active(0);
|
||||
assert_eq!(snapshot_names(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// Automatically Create New Snapshot When Saving: off (the default), a save adds nothing; on,
|
||||
/// each save adds a snapshot of the document as saved, named with the time, and not a
|
||||
/// history state.
|
||||
#[test]
|
||||
fn a_save_adds_a_snapshot_only_with_the_option() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
assert_eq!(snapshot_on_save(&mut s), None);
|
||||
assert_eq!(snapshot_names(&s).len(), 1, "only the first snapshot");
|
||||
|
||||
s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.active_mut().unwrap().saved_to("saved.psd".into());
|
||||
let states = entries(&s);
|
||||
let id = snapshot_on_save(&mut s).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let snap = st.snapshots.last().unwrap();
|
||||
assert_eq!(snap.id, id);
|
||||
assert!(std::sync::Arc::ptr_eq(&snap.doc, &st.doc), "the document as saved");
|
||||
// HH:MM:SS, the local time of the save.
|
||||
let parts: Vec<&str> = snap.name.split(':').collect();
|
||||
assert!(parts.len() == 3 && parts.iter().all(|p| p.len() == 2 && p.bytes().all(|b| b.is_ascii_digit())), "{}", snap.name);
|
||||
assert_eq!(entries(&s), states, "not a history state");
|
||||
assert!(!s.active().unwrap().is_dirty(), "the saved document stays clean");
|
||||
// Each save adds one; selecting it brings that save back.
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
let second = snapshot_on_save(&mut s).unwrap();
|
||||
assert_eq!(snapshot_names(&s).len(), 3);
|
||||
s.execute("history.selectSnapshot", json!({"snapshot": id})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(second > id);
|
||||
// No document: nothing to do, no panic.
|
||||
let mut empty = Session::new();
|
||||
empty.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
|
||||
assert_eq!(snapshot_on_save(&mut empty), None);
|
||||
}
|
||||
|
||||
/// Saving an unchanged document again (⌘S on a clean document) adds no duplicate row; a
|
||||
/// change, or deleting the previous save's snapshot, makes the next save add one again.
|
||||
#[test]
|
||||
fn saving_an_unchanged_document_adds_no_duplicate_snapshot() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
|
||||
let first = snapshot_on_save(&mut s).unwrap();
|
||||
assert_eq!(snapshot_on_save(&mut s), None, "unchanged since the last save snapshot");
|
||||
assert_eq!(snapshot_names(&s).len(), 2, "the first snapshot and one save snapshot");
|
||||
// A view-only change (no document change) doesn't count either.
|
||||
s.active_mut().unwrap().revision += 1;
|
||||
assert_eq!(snapshot_on_save(&mut s), None);
|
||||
// An edit does.
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
let second = snapshot_on_save(&mut s).unwrap();
|
||||
assert!(second > first);
|
||||
assert_eq!(snapshot_on_save(&mut s), None);
|
||||
// The previous save's snapshot deleted: the next save shows the document again.
|
||||
s.execute("history.deleteSnapshot", json!({"snapshot": second})).unwrap();
|
||||
assert!(snapshot_on_save(&mut s).is_some());
|
||||
assert_eq!(snapshot_names(&s).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_new_snapshot_dialog_reads_the_option() {
|
||||
let mut s = Session::new();
|
||||
assert!(!show_new_snapshot_dialog(&s));
|
||||
s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": true})).unwrap();
|
||||
assert!(show_new_snapshot_dialog(&s));
|
||||
assert_eq!(crate::inspect::session(&s)["historyOptions"]["showNewSnapshotDialogByDefault"], json!(true));
|
||||
}
|
||||
|
||||
/// The first snapshot and a save snapshot are recorded steps like any other: with Make Layer
|
||||
/// Visibility Changes Undoable off, selecting one that shows a layer the eye hid, then Undo,
|
||||
/// hides the layer again, and Redo shows it (the step changed it, #3535's rule).
|
||||
#[test]
|
||||
fn selecting_a_snapshot_undoes_its_own_visibility_change() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
// Red, not the white document as opened (`red` only reads the red channel).
|
||||
let red = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().surface().unwrap().pixel(1, 1)[1] < 0.5;
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false, "automaticallyCreateNewSnapshotWhenSaving": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.active_mut().unwrap().saved_to("saved.psd".into());
|
||||
let saved = snapshot_on_save(&mut s).unwrap();
|
||||
// The eye hides the layer: no state.
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
for (snap, is_red) in [(0, false), (saved, true)] {
|
||||
s.execute("history.selectSnapshot", json!({"snapshot": snap})).unwrap();
|
||||
assert!(visible(&s, id) && red(&s) == is_red, "snapshot {snap} shows the layer as it was");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!visible(&s, id) && red(&s), "undoing snapshot {snap} hides the layer again");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(visible(&s, id) && red(&s) == is_red, "redoing snapshot {snap} shows it");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// With the oldest state current, non-linear Delete is still enabled for a later (dimmed)
|
||||
/// state (the History panel's trash and its drop target ask for it); linear history has
|
||||
/// nothing to delete there.
|
||||
|
||||
@@ -366,6 +366,7 @@ mod tests {
|
||||
json!({"state": 1.5}),
|
||||
json!({"state": "1"}),
|
||||
json!({"state": [1]}),
|
||||
json!({"snapshot": 99}),
|
||||
json!({"snapshot": 1}),
|
||||
json!({"snapshot": -1}),
|
||||
json!({"snapshot": "Open"}),
|
||||
@@ -375,7 +376,7 @@ mod tests {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed");
|
||||
// Snapshot 0 is the document as opened, so it is a valid source.
|
||||
// Snapshot 0 is the document as opened (Automatically Create First Snapshot), so it is a valid source.
|
||||
assert_eq!(s.execute(CMD, json!({"snapshot": 0})).unwrap()["snapshot"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
|
||||
//! bit depth) conversions, Duplicate.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
|
||||
use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size};
|
||||
use photocraft_geom::Rect;
|
||||
@@ -11,6 +12,7 @@ use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::document_preset_cmds::MAX_RESOLUTION;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -235,15 +237,27 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pw = pw.or(Some(ow as f64 * pc / 100.0));
|
||||
ph = ph.or(Some(oh as f64 * pc / 100.0));
|
||||
}
|
||||
// The document range File › New and the dialog's field allow; outside it, refuse rather than
|
||||
// clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own
|
||||
// resolution always passes: an imported file may have one outside the range, and the dialog
|
||||
// sends it back unchanged (exactly: a value that only rounds to it as f32 is refused).
|
||||
let own_dpi = f64::from(d.doc.resolution_dpi);
|
||||
let dpi = match p.get("resolution") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || r == own_dpi));
|
||||
Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?)
|
||||
}
|
||||
};
|
||||
// `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size
|
||||
// with Resample on, as an action records it).
|
||||
// with Resample on, as an action records it). An unchanged resolution keeps the pixels.
|
||||
if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true)
|
||||
&& pw.is_none()
|
||||
&& ph.is_none()
|
||||
&& let Some(r) = p.get("resolution").and_then(Value::as_f64)
|
||||
&& let Some(r) = dpi
|
||||
&& r != own_dpi
|
||||
{
|
||||
let r = r.clamp(1.0, 10_000.0);
|
||||
let old = f64::from(d.doc.resolution_dpi).max(1.0);
|
||||
let old = own_dpi.max(1.0);
|
||||
pw = Some(ow as f64 * r / old);
|
||||
ph = Some(oh as f64 * r / old);
|
||||
}
|
||||
@@ -260,7 +274,6 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) {
|
||||
check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?;
|
||||
}
|
||||
let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true);
|
||||
if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) {
|
||||
// Before the sampler map or the document change: a resize that doesn't fit would trap on the web (#10024).
|
||||
@@ -272,7 +285,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Image Size", |doc, _| {
|
||||
if let Some(r) = dpi {
|
||||
// Type keeps its pixel size; resampling below then scales it with the pixels.
|
||||
crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0));
|
||||
crate::canvas_geom::set_resolution(doc, r as f32);
|
||||
}
|
||||
let filter = match resample {
|
||||
Method::None => return Ok(()),
|
||||
@@ -280,8 +293,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails,
|
||||
Method::Automatic => Resample::BicubicSharper,
|
||||
};
|
||||
resample_doc_with_styles(doc, nw, nh, filter, scale_styles);
|
||||
Ok(())
|
||||
resample_doc_with_styles(doc, "image.imageSize", nw, nh, filter, scale_styles)
|
||||
})?;
|
||||
// The size the document has now: without resampling `width`/`height` are ignored (#1815).
|
||||
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
|
||||
@@ -316,18 +328,20 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32,
|
||||
|
||||
/// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to
|
||||
/// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution
|
||||
/// mode. Callers check [`check_resample_budget`] first.
|
||||
pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) {
|
||||
resample_doc_with_styles(doc, nw, nh, filter, true);
|
||||
/// mode. Callers check [`check_resample_budget`] for the new canvas first; the pixels outside the
|
||||
/// canvas are scaled too, so a surface they would grow past the budget is refused here (#9212).
|
||||
pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample) -> Result<()> {
|
||||
resample_doc_with_styles(doc, cmd, nw, nh, filter, true)
|
||||
}
|
||||
|
||||
/// Resize document content while optionally retaining layer-effect sizes in pixels.
|
||||
fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) {
|
||||
fn resample_doc_with_styles(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample, scale_styles: bool) -> Result<()> {
|
||||
let (ow, oh) = (doc.size.width, doc.size.height);
|
||||
let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64);
|
||||
if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
check_hidden_resample_budget(cmd, doc, sx, sy, filter)?;
|
||||
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
|
||||
let canvas = Rect::from_xywh(0, 0, ow, oh);
|
||||
let taken = crate::smart_cmds::take_caches(&mut doc.layers);
|
||||
@@ -358,6 +372,76 @@ fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resamp
|
||||
doc.size = Size::new(nw, nh);
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
|
||||
let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`check_resample_budget`] for the largest surface [`resample_doc`] stores while scaling by
|
||||
/// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its
|
||||
/// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and
|
||||
/// those grow with the scale like the rest. A bicubic shrink also stores the image between its
|
||||
/// two passes, one axis still at its old size ([`largest_resize_rect`]). The refusal suggests
|
||||
/// Delete Cropped Pixels only when the surfaces over the budget are all ones it clips
|
||||
/// ([`delete_cropped_pixels`]).
|
||||
fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> {
|
||||
let new_canvas = scaled_rect(doc.bounds(), sx, sy);
|
||||
// The surfaces Delete Cropped Pixels clips; masks, channels, the selection, type, shapes and
|
||||
// smart objects keep their pixels outside the crop.
|
||||
let mut clipped: HashSet<*const Surface> = HashSet::new();
|
||||
for_each_layer(&mut doc.layers, &mut |l| {
|
||||
clipped.extend(l.fill_cache.as_ref().map(|fc| std::ptr::from_ref(&fc.surface)));
|
||||
if let Some(v) = &l.video {
|
||||
clipped.extend(v.frames.iter().chain(v.original_frames.iter().flatten()).map(std::ptr::from_ref));
|
||||
}
|
||||
if let LayerContent::Raster(s) = &l.content {
|
||||
clipped.insert(std::ptr::from_ref(s));
|
||||
}
|
||||
});
|
||||
let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp);
|
||||
// The largest surface by bytes, and the largest one Delete Cropped Pixels doesn't clip: its
|
||||
// width, height and bytes per pixel (a mask has one channel, the layers the document's).
|
||||
let (mut largest, mut largest_kept) = ((0u32, 0u32, 1u64), (0u32, 0u32, 1u64));
|
||||
let mut visit = |surf: &Surface, filter: Resample| {
|
||||
let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter);
|
||||
// What stays within the new canvas is already budgeted.
|
||||
if r.is_empty() || new_canvas.union(&r) == new_canvas {
|
||||
return;
|
||||
}
|
||||
let this = (r.width(), r.height(), surf.format().bytes_per_pixel().max(1) as u64);
|
||||
if bytes(this) > bytes(largest) {
|
||||
largest = this;
|
||||
}
|
||||
if !clipped.contains(&std::ptr::from_ref(surf)) && bytes(this) > bytes(largest_kept) {
|
||||
largest_kept = this;
|
||||
}
|
||||
};
|
||||
// The surfaces `resample_doc` scales, smart object caches included.
|
||||
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| visit(surf, if is_mask { Resample::Bilinear } else { filter }));
|
||||
for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) {
|
||||
visit(&ch.surface, Resample::Bilinear);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
visit(sel, Resample::Bilinear);
|
||||
}
|
||||
let (kept, (w, h, bpp)) = if bytes(largest_kept) > MAX_RESAMPLE_BYTES {
|
||||
(true, largest_kept)
|
||||
} else if bytes(largest) > MAX_RESAMPLE_BYTES {
|
||||
(false, largest)
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
let hint = if kept {
|
||||
"choose a smaller size (layer masks, channels, the quick mask, the selection, type, shapes and smart objects keep theirs through Crop with Delete Cropped Pixels)"
|
||||
} else {
|
||||
"choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)"
|
||||
};
|
||||
let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000;
|
||||
Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: {hint}",
|
||||
u64::from(w).saturating_mul(u64::from(h)) / 1_000_000
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
@@ -714,6 +798,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
|
||||
// Only the Crop tool's frame (an explicit rectangle) resizes a canvas-sized artboard; a
|
||||
// selection crop leaves boards as they are.
|
||||
let tool_frame = explicit.is_some();
|
||||
let mut filled = false;
|
||||
s.edit("Crop", |doc, active| {
|
||||
let original = doc.size;
|
||||
let board = if tool_frame { canvas_artboard(doc) } else { None };
|
||||
@@ -725,12 +810,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
|
||||
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
|
||||
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
|
||||
if content_aware {
|
||||
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
|
||||
filled = crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height), delete)?;
|
||||
}
|
||||
doc.selection = None;
|
||||
target.apply(doc);
|
||||
Ok(())
|
||||
target.apply(doc)
|
||||
})?;
|
||||
if filled && let Some(st) = s.active_mut() {
|
||||
st.history.set_current_label("Content-aware Crop");
|
||||
}
|
||||
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })))
|
||||
}
|
||||
|
||||
@@ -744,15 +831,16 @@ struct CropTarget {
|
||||
|
||||
impl CropTarget {
|
||||
/// Applies the target to a document that has just been cropped (inside the crop's history step).
|
||||
fn apply(&self, doc: &mut Document) {
|
||||
fn apply(&self, doc: &mut Document) -> Result<()> {
|
||||
if let Some((w, h)) = self.size {
|
||||
// As for Image Size: an Indexed Color document holds only its colour table's colours (#6019).
|
||||
let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter };
|
||||
resample_doc(doc, w, h, filter);
|
||||
resample_doc(doc, "image.crop", w, h, filter)?;
|
||||
}
|
||||
if let Some(r) = self.resolution {
|
||||
doc.resolution_dpi = r;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels
|
||||
@@ -897,6 +985,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
||||
};
|
||||
// The canvas board, found before the turn, fits the frame as in a straight crop.
|
||||
let board = s.active().and_then(|d| canvas_artboard(&d.doc));
|
||||
let mut filled = false;
|
||||
crate::analysis_cmds::compound(s, "Crop", |s| {
|
||||
// Pixels the crop deletes needn't be turned first: keeping only what lands in the frame
|
||||
// makes the rotation cost the frame's size, not the canvas's (a small turned crop of a
|
||||
@@ -934,13 +1023,15 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
||||
crop_doc(doc, CMD, target, delete, extension)?;
|
||||
fit_canvas_artboard(doc, board);
|
||||
if content_aware {
|
||||
crate::crop_fill::fill_uncovered(doc, *active, &coverage)?;
|
||||
filled = crate::crop_fill::fill_uncovered(doc, *active, &coverage, delete)?;
|
||||
}
|
||||
doc.selection = None;
|
||||
crop_target.apply(doc);
|
||||
Ok(())
|
||||
crop_target.apply(doc)
|
||||
})
|
||||
})?;
|
||||
if filled && let Some(st) = s.active_mut() {
|
||||
st.history.set_current_label("Content-aware Crop");
|
||||
}
|
||||
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle })))
|
||||
}
|
||||
|
||||
@@ -1109,7 +1200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.crop",
|
||||
"Crop",
|
||||
["Image"],
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
|
||||
has_doc,
|
||||
crop
|
||||
),
|
||||
|
||||
@@ -234,6 +234,48 @@ fn resolution_only_change() {
|
||||
assert_eq!(r, json!({"width": 40, "height": 20}));
|
||||
}
|
||||
|
||||
/// #9260: a resolution outside the document range (1..=30000 ppi, as File › New and the
|
||||
/// dialog's field) is refused and leaves the document as it was, instead of being clamped;
|
||||
/// one inside it, also above 10000, is set as given.
|
||||
#[test]
|
||||
fn image_size_refuses_a_resolution_out_of_range() {
|
||||
for extra in [json!({"resample": "none"}), json!({"resample": "bicubic"}), json!({"resample": "bicubic", "keepPrintSize": true})] {
|
||||
for r in [json!(1e39), json!(-5), json!(0), json!(0.5), json!(30_001), json!("300")] {
|
||||
let mut s = session();
|
||||
let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len());
|
||||
let mut p = extra.clone();
|
||||
p["resolution"] = r;
|
||||
assert!(s.execute("image.imageSize", p.clone()).is_err(), "{p}");
|
||||
assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed");
|
||||
}
|
||||
}
|
||||
let mut s = session();
|
||||
s.execute("image.imageSize", json!({"resolution": 20_000, "resample": "none"})).unwrap();
|
||||
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0));
|
||||
s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap();
|
||||
assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is");
|
||||
// An imported document's own resolution outside the range (a PNG pHYs of 1 px/m is
|
||||
// 0.0254 ppi) still resizes: the Image Size dialog sends it back unchanged on OK.
|
||||
for own in [0.0254_f32, 40_000.0] {
|
||||
let mut s = session();
|
||||
s.edit("ppi", |doc, _| {
|
||||
doc.resolution_dpi = own;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap();
|
||||
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}");
|
||||
assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked");
|
||||
// Only the exact value: one that merely rounds to it as f32 is refused.
|
||||
let near = if own < 1.0 { f64::from(own) * (1.0 + 1e-9) } else { f64::from(own) + 0.001 };
|
||||
assert_eq!(near as f32, own);
|
||||
assert!(s.execute("image.imageSize", json!({"resolution": near, "resample": "none"})).is_err(), "{own}: {near}");
|
||||
// keepPrintSize with the unchanged resolution keeps the pixels.
|
||||
s.execute("image.imageSize", json!({"resolution": f64::from(own), "resample": "bicubic", "keepPrintSize": true})).unwrap();
|
||||
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}: keepPrintSize");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is
|
||||
/// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump.
|
||||
#[test]
|
||||
@@ -825,6 +867,79 @@ fn crop_target_accepts_whole_number_floats() {
|
||||
assert_eq!(doc(&s).size, Size::new(40, 20));
|
||||
}
|
||||
|
||||
/// #9212: a non-deleting crop keeps the pixels outside the frame, and W x H resamples them
|
||||
/// too. A 20 px frame of a 2000 px Background resampled to 2000 px scales its hidden pixels
|
||||
/// x100, to 200000 px a side (160 GB at 8 bits, past the 8 GiB budget): refused before
|
||||
/// allocating, like a target size past the budget.
|
||||
#[test]
|
||||
fn crop_target_budgets_the_hidden_pixels_it_resamples() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2000, "height": 2000})).unwrap();
|
||||
s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [500, 700]})).unwrap();
|
||||
let symmetry = s.active().unwrap().symmetry.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "deleteCroppedPixels": false, "targetWidth": 2000, "targetHeight": 2000});
|
||||
for angle in [None, Some(90)] {
|
||||
let mut p = p.clone();
|
||||
if let Some(a) = angle {
|
||||
p["angle"] = json!(a);
|
||||
}
|
||||
let err = s.execute("image.crop", p).unwrap_err().to_string();
|
||||
// Since the crop turns the Background into Layer 0 (#10867), the turned layer's
|
||||
// bounds are 2001 px a side, resampled x100 to 200100.
|
||||
let want = if angle.is_some() { "200100 x 200100 px is 40040 megapixels" } else { "200000 x 200000 px is 40000 megapixels" };
|
||||
assert!(err.contains(want) && err.contains("Delete Cropped Pixels"), "{angle:?}: {err}");
|
||||
assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step");
|
||||
// The turned crop rotates first: the painting symmetry turns back with the document.
|
||||
assert_eq!(s.active().unwrap().symmetry, symmetry, "{angle:?}: the painting symmetry stays");
|
||||
}
|
||||
// Deleting the cropped pixels leaves only the frame to resample.
|
||||
p["deleteCroppedPixels"] = json!(true);
|
||||
s.execute("image.crop", p).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(2000, 2000));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
|
||||
}
|
||||
|
||||
/// #9212: Image Size resamples the pixels outside the canvas as well. After a non-deleting
|
||||
/// crop to 10 px, 20000 px (400 MP, within the budget) scales the hidden 100 px Background x2000.
|
||||
#[test]
|
||||
fn image_size_budgets_the_hidden_pixels_it_resamples() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string();
|
||||
assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}");
|
||||
// The hidden pixels are a pixel layer's: Delete Cropped Pixels would clear them.
|
||||
assert!(err.contains("or delete them first (Crop with Delete Cropped Pixels)"), "{err}");
|
||||
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past));
|
||||
// Without resampling only the resolution changes.
|
||||
s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap();
|
||||
assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0));
|
||||
}
|
||||
|
||||
/// Delete Cropped Pixels clips pixel layers only: an alpha channel keeps its pixels outside the
|
||||
/// crop, so the refusal doesn't send the user to an option that can't clear it.
|
||||
#[test]
|
||||
fn hidden_resample_budget_names_what_delete_cropped_pixels_keeps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
s.edit("channel", |doc, _| {
|
||||
let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false);
|
||||
let mut ch = Surface::new(fmt);
|
||||
ch.write_region(Rect::new(0, 0, 100, 100), &[1.0; 100 * 100]);
|
||||
doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": true})).unwrap();
|
||||
let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string();
|
||||
assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}");
|
||||
assert!(err.contains("channels") && !err.contains("delete them first"), "{err}");
|
||||
assert_eq!(doc(&s).size, Size::new(10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_target_rejects_bad_params_without_changing_the_document() {
|
||||
let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10});
|
||||
@@ -973,6 +1088,24 @@ fn duplicate_document() {
|
||||
assert!(doc(&s).name.ends_with("copy"));
|
||||
}
|
||||
|
||||
/// #9212: a bicubic shrink stores the image between its two passes with one axis at its old
|
||||
/// size. A Background whose kept pixels reach (69999, 69999), halved from 200 to 100 px, ends at
|
||||
/// about 35000 x 35000 px (4.9 GB, within the 8 GiB budget) but passes through about
|
||||
/// 35000 x 70000 px (9.8 GB): refused before allocating. Bilinear has one pass and fits. The
|
||||
/// check alone runs, so nothing that large is allocated either way.
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
#[test]
|
||||
fn hidden_resample_budget_counts_the_image_between_bicubic_passes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let mut d = doc(&s).clone();
|
||||
let bg = d.background_mut().unwrap().surface_mut().unwrap();
|
||||
bg.fill_rect(Rect::new(69_999, 69_999, 70_000, 70_000), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let err = check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bicubic).unwrap_err().to_string();
|
||||
assert!(err.contains(" x 70000 px is "), "{err}");
|
||||
check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bilinear).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"layer.lockLayers",
|
||||
"Lock Layers…",
|
||||
&["Layer"],
|
||||
None,
|
||||
Some("Cmd+/"),
|
||||
r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##,
|
||||
has_layer,
|
||||
lock_layers
|
||||
|
||||
@@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Bitmap", |doc, active| {
|
||||
if (nw, nh) != (doc.size.width, doc.size.height) {
|
||||
crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic);
|
||||
crate::image_cmds::resample_doc(doc, CMD, nw, nh, photocraft_algo::resample::Resample::Bicubic)?;
|
||||
}
|
||||
doc.resolution_dpi = dpi;
|
||||
let w = doc.size.width as usize;
|
||||
|
||||
@@ -452,6 +452,18 @@ fn bitmap_output_resolution_resamples_and_sets_the_resolution() {
|
||||
assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128));
|
||||
}
|
||||
|
||||
/// #9212: the Output resolution resamples the pixels outside the canvas too. A 10 px crop that
|
||||
/// kept a 1000 px document's pixels, converted from 1 to 100 ppi, would grow them to 100000 px a
|
||||
/// side (20 GB of gray and alpha at 8 bits, past the 8 GiB budget): refused before allocating.
|
||||
#[test]
|
||||
fn bitmap_output_resolution_budgets_the_hidden_pixels() {
|
||||
let mut s = ramp(1000, 1000, 8, 1.0);
|
||||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap();
|
||||
let err = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 100})).unwrap_err().to_string();
|
||||
assert!(err.contains("100000 x 100000 px is 10000 megapixels"), "{err}");
|
||||
assert_eq!((doc(&s).size.width, doc(&s).mode), (10, ColorMode::Grayscale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() {
|
||||
// The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the
|
||||
|
||||
@@ -163,6 +163,11 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result<Homography> {
|
||||
/// scale/rotate/move, refused when the result would not fit the transform budget.
|
||||
fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "select.transformSelection";
|
||||
let step_name = match p.get("stepName") {
|
||||
None | Some(Value::Null) => "Transform Selection",
|
||||
Some(Value::String(n)) if n == "Move Selection" => "Move Selection",
|
||||
Some(_) => return Err(bad(CMD, "`stepName` must be \"Move Selection\"")),
|
||||
};
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = d.doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?;
|
||||
let b = sel.content_bounds();
|
||||
@@ -206,7 +211,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if h.0.iter().zip(Homography::IDENTITY.0).all(|(a, b)| (a - b).abs() < 1e-12) {
|
||||
return Ok(json!({"changed": false, "selected": true}));
|
||||
}
|
||||
let selected = s.edit("Transform Selection", |doc, _| {
|
||||
let selected = s.edit(step_name, |doc, _| {
|
||||
if let Some(sel) = &doc.selection {
|
||||
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?;
|
||||
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty());
|
||||
@@ -250,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Transform Selection",
|
||||
menu: &["Select"],
|
||||
shortcut: None,
|
||||
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
|
||||
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"stepName":"Move Selection"? (History step name for a plain move; the UI sends it for a drag of the outline; default "Transform Selection"; any other value is refused),"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
|
||||
enabled: has_selection,
|
||||
run: transform_selection,
|
||||
journal: true,
|
||||
@@ -422,6 +427,23 @@ mod tests {
|
||||
assert!(s.execute("select.transformSelection", json!({"scaleX": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_selection_step_name_is_restricted() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 60, "height": 40})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let last = |s: &Session| s.active().unwrap().history.entries().last().cloned().unwrap();
|
||||
s.execute("select.transformSelection", json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert_eq!(last(&s), "Transform Selection");
|
||||
s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": "Move Selection"})).unwrap();
|
||||
assert_eq!(last(&s), "Move Selection");
|
||||
let n = s.active().unwrap().history.past_len();
|
||||
for bad_name in [json!("Crop"), json!(""), json!(7)] {
|
||||
assert!(s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": bad_name})).is_err());
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_selection_needs_a_selection() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -621,7 +621,9 @@ fn lasso(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.len() < 3 {
|
||||
return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() });
|
||||
}
|
||||
polygon_selection(s, "select.lasso", "Lasso", &pts, p)
|
||||
// The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop).
|
||||
let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" };
|
||||
polygon_selection(s, "select.lasso", step, &pts, p)
|
||||
}
|
||||
|
||||
/// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as
|
||||
@@ -754,7 +756,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"select.lasso",
|
||||
"Lasso",
|
||||
[],
|
||||
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0}"##,
|
||||
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##,
|
||||
has_doc,
|
||||
lasso
|
||||
),
|
||||
|
||||
@@ -101,6 +101,48 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> {
|
||||
_ => Err("the smart object is embedded".into()),
|
||||
}
|
||||
}
|
||||
fn linked_layers(doc: &Document) -> Vec<LayerId> {
|
||||
doc.walk()
|
||||
.into_iter()
|
||||
.filter(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if matches!(sm.source, SmartSource::Linked { .. })))
|
||||
.map(|(_, _, l)| l.id)
|
||||
.collect()
|
||||
}
|
||||
fn has_any_linked(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
if linked_layers(&d.doc).is_empty() { Err("the document has no linked smart object".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Embed All Linked: reads every linked file first, so a missing one refuses the whole step.
|
||||
fn embed_all_linked(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let ids = linked_layers(doc);
|
||||
if ids.is_empty() {
|
||||
return Err(other("the document has no linked smart object"));
|
||||
}
|
||||
let mut sources = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let src = &smart(doc, *id)?.source;
|
||||
let (file_name, bytes) = source_bytes(&doc.metadata, src).ok_or_else(|| {
|
||||
let path = match src {
|
||||
SmartSource::Linked { path } => path.as_str(),
|
||||
SmartSource::Embedded { .. } => "",
|
||||
};
|
||||
other(format!("the linked file can't be read: {path}"))
|
||||
})?;
|
||||
sources.push(SmartSource::Embedded { file_name, bytes });
|
||||
}
|
||||
let parent = doc.id;
|
||||
let contents: Vec<SmartContentsId> = ids.iter().map(|id| smart(doc, *id).map(|sm| sm.contents_id)).collect::<Result<_>>()?;
|
||||
s.edit("Embed All Linked", |doc, _| {
|
||||
for (id, src) in ids.iter().zip(sources) {
|
||||
smart_mut(doc, *id)?.source = src;
|
||||
refresh_or_fail(doc, *id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.smart_links.retain(|l| l.parent != parent || !contents.contains(&l.contents_id));
|
||||
Ok(json!({"embedded": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
@@ -146,7 +188,6 @@ mod source;
|
||||
pub(crate) use contents::on_close;
|
||||
pub use contents::{commit_child, contents_of, replace_contents_bytes, replace_contents_from_bytes};
|
||||
use contents::{edit_contents, export_contents, is_smart_child, path_param, replace_contents, set_source, update_all};
|
||||
#[cfg(test)]
|
||||
use filters::smart_mut;
|
||||
pub(crate) use filters::{add_smart_filter, add_smart_filter_in, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit};
|
||||
use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters};
|
||||
@@ -244,12 +285,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_smart,
|
||||
unpack::convert_to_layers
|
||||
),
|
||||
spec!("layer.smartObjects.convertToEmbedded", "Convert to Embedded", SO, r##"{"layer":id?}"##, has_linked, |s, p| {
|
||||
spec!("layer.smartObjects.convertToEmbedded", "Embed Linked", SO, r##"{"layer":id?}"##, has_linked, |s, p| {
|
||||
set_source(s, p, "Convert to Embedded", true, |meta, src| {
|
||||
let (file_name, bytes) = source_bytes(meta, src).ok_or_else(|| other("the linked file can't be read"))?;
|
||||
Ok(SmartSource::Embedded { file_name, bytes })
|
||||
})
|
||||
}),
|
||||
spec!(
|
||||
"layer.smartObjects.embedAllLinked",
|
||||
"Embed All Linked",
|
||||
SO,
|
||||
"{} (every linked smart object of the active document becomes embedded in one history step; a linked file that can't be read refuses the whole step and the error names it) -> {embedded: [layer ids]}",
|
||||
has_any_linked,
|
||||
embed_all_linked
|
||||
),
|
||||
spec!(
|
||||
"layer.smartObjects.convertToLinked",
|
||||
"Convert to Linked…",
|
||||
|
||||
@@ -17,6 +17,9 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().and_then(|d| d.active_layer).map(|_| ()).ok_or_else(|| "no active layer".into())
|
||||
}
|
||||
|
||||
/// The History step name of a shape-tool bounding box scale (`edit.transform` `stepName`).
|
||||
pub const TRANSFORM_PATH: &str = "Transform Path";
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: "edit.transform".into(), msg: msg.into() }
|
||||
}
|
||||
@@ -841,6 +844,13 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The shape tools' bounding box scales live shapes with this command and names the step as
|
||||
// the reference does. Passed in, not renamed after: the edit may land later as a job.
|
||||
let step_name = match p.get("stepName") {
|
||||
None | Some(Value::Null) => "Free Transform",
|
||||
Some(Value::String(n)) if n == TRANSFORM_PATH => TRANSFORM_PATH,
|
||||
Some(_) => return Err(bad("`stepName` must be \"Transform Path\"")),
|
||||
};
|
||||
let p = &with_panel_target(s, p);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let many = layers_param(&st.doc, p)?;
|
||||
@@ -920,7 +930,7 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// on a worker with progress, and can be cancelled.
|
||||
crate::jobs::edit_job(
|
||||
s,
|
||||
"Free Transform",
|
||||
step_name,
|
||||
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)),
|
||||
move |()| json!({"rect": rect, "layers": layers}),
|
||||
)
|
||||
@@ -1006,7 +1016,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Free Transform",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||
enabled: has_layer,
|
||||
journal: true,
|
||||
run: transform,
|
||||
|
||||
@@ -970,3 +970,24 @@ fn split_selected_lifts_up_to_the_selection_and_leaves_the_rest() {
|
||||
assert!((l + r - a).abs() < 1e-6, "k = {k}: lifted {l} + left {r} != {a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `stepName` names the step "Transform Path" (the shape tools' bounding box); without it the
|
||||
/// step stays "Free Transform"; any other name is refused and changes nothing.
|
||||
#[test]
|
||||
fn step_name_transform_path_is_the_only_name_taken() {
|
||||
let mut s = session();
|
||||
let p = json!({"scaleX": 50, "scaleY": 50});
|
||||
s.execute("edit.transform", p.clone()).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Free Transform"));
|
||||
let mut named = p.clone();
|
||||
named["stepName"] = json!("Transform Path");
|
||||
s.execute("edit.transform", named).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Transform Path"));
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for bad_name in [json!("Free Transform"), json!("Move"), json!(""), json!(3), json!(["Transform Path"])] {
|
||||
let mut wrong = p.clone();
|
||||
wrong["stepName"] = bad_name.clone();
|
||||
assert!(s.execute("edit.transform", wrong).is_err(), "{bad_name}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps, "a refused name changes nothing");
|
||||
}
|
||||
|
||||
@@ -315,13 +315,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"subtract",
|
||||
"Subtract Front Shape"
|
||||
)),
|
||||
spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
|
||||
spec!("layer.combineShapes.intersectShapeAreas", "Unite Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
|
||||
s,
|
||||
p,
|
||||
"intersect",
|
||||
"Intersect Shape Areas"
|
||||
)),
|
||||
spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| {
|
||||
spec!("layer.combineShapes.excludeOverlappingShapes", "Subtract Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| {
|
||||
combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes")
|
||||
}),
|
||||
spec!(
|
||||
|
||||
@@ -110,6 +110,15 @@ fn blank_of(fmt: photocraft_color::PixelFormat) -> Surface {
|
||||
Surface::new(fmt)
|
||||
}
|
||||
|
||||
/// Both the altered and original footage stacks must have room for an added frame.
|
||||
fn check_frame_growth(frames: usize, original_frames: Option<usize>) -> Result<()> {
|
||||
let limit = crate::timeline_cmds::MAX_DURATION;
|
||||
if frames.max(original_frames.unwrap_or(0)) >= limit {
|
||||
return Err(EngineError::Other(format!("a video layer is limited to {limit} frames")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_blank(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let id = s.edit("New Blank Video Layer", |doc, active| {
|
||||
store(doc);
|
||||
@@ -138,7 +147,8 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn insert_blank(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format();
|
||||
edit_video(s, "Insert Blank Frame", move |v, cur| {
|
||||
let at = (cur + 1).min(v.frames.len());
|
||||
check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?;
|
||||
let at = cur.saturating_add(1).min(v.frames.len());
|
||||
v.frames.insert(at, blank_of(fmt));
|
||||
if let Some(original) = &mut v.original_frames {
|
||||
// The stacks stay aligned, but never index past a shorter one.
|
||||
@@ -150,7 +160,9 @@ fn insert_blank(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn duplicate_frame(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
edit_video(s, "Duplicate Frame", |v, cur| {
|
||||
if let Some(f) = v.frames.get(cur).cloned() {
|
||||
if let Some(f) = v.frames.get(cur) {
|
||||
check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?;
|
||||
let f = f.clone();
|
||||
if let Some(original) = &mut v.original_frames {
|
||||
let orig = original.get(cur).cloned().unwrap_or_else(|| blank_of(f.format()));
|
||||
original.insert((cur + 1).min(original.len()), orig);
|
||||
|
||||
@@ -29,6 +29,17 @@ fn new_blank_insert_duplicate_delete_frames() {
|
||||
assert!(s.active().unwrap().doc.timeline.as_ref().unwrap().duration >= frames(&s, id));
|
||||
}
|
||||
|
||||
/// Check the shared growth limit without constructing a million-frame document.
|
||||
#[test]
|
||||
fn frame_growth_stops_at_the_limit_of_either_stack() {
|
||||
let limit = crate::timeline_cmds::MAX_DURATION;
|
||||
assert!(check_frame_growth(limit - 1, None).is_ok());
|
||||
assert!(check_frame_growth(limit - 1, Some(limit - 1)).is_ok());
|
||||
assert!(check_frame_growth(limit, None).is_err());
|
||||
assert!(check_frame_growth(limit - 1, Some(limit)).is_err());
|
||||
assert!(check_frame_growth(limit + 1, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_drops_the_stack() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -664,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec {
|
||||
id: "layer.smartObjects.warp",
|
||||
label: "Warp",
|
||||
menu: &["Layer", "Smart Objects"],
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: P,
|
||||
enabled: active_smart,
|
||||
|
||||
+138
-46
@@ -3,9 +3,10 @@
|
||||
//! strips an extension adds) is filled from its surroundings instead of holding the background
|
||||
//! colour or transparency.
|
||||
//!
|
||||
//! Which layer: the Background when the document has one (the layer the crop filled with the
|
||||
//! background colour), otherwise the active pixel layer (where the crop left transparency). Other
|
||||
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
|
||||
//! Which layers: the Background and the active pixel layer, each sampled independently. Unmeasured
|
||||
//! cases (Delete Cropped Pixels off, or a hidden, locked or nonpixel active layer) retain the
|
||||
//! Background-else-active target. Other layers keep their transparency, as after a plain crop.
|
||||
//! Each connected piece of the
|
||||
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
|
||||
//! window around that piece, and only pixels the image covers are sampled.
|
||||
//!
|
||||
@@ -27,6 +28,9 @@ const SEED: u64 = 1;
|
||||
/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap.
|
||||
const MAX_PIXELS: u64 = 400_000_000;
|
||||
|
||||
/// Limit for the dense native-float input read when reaching beyond the normal sampling window.
|
||||
const MAX_WIDENED_SAMPLE_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Where the cropped canvas came from in the original document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Coverage {
|
||||
@@ -38,10 +42,10 @@ pub(crate) struct Coverage {
|
||||
pub height: f64,
|
||||
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
|
||||
pub rotated: bool,
|
||||
/// The filled layer's pixels before a turned crop (original document px), set by
|
||||
/// Each candidate target's pixels before a turned crop (original document px), set by
|
||||
/// [`Coverage::holding`]: where they lie past the old canvas they are hidden image, not
|
||||
/// uncovered area. A straight crop has none: Photoshop fills over hidden pixels there.
|
||||
pub held: Option<Surface>,
|
||||
pub held: Option<Vec<(LayerId, Surface)>>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
@@ -53,7 +57,14 @@ impl Coverage {
|
||||
/// Takes the pixels of the layer the fill will go to from `doc` before a turned crop (the
|
||||
/// surface is shared, not copied), so hidden pixels past the old canvas count as covered.
|
||||
pub fn holding(mut self, doc: &Document, active: Option<LayerId>) -> Self {
|
||||
self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned();
|
||||
self.held = Some(
|
||||
target_layer(doc, active)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.chain(additional_target(doc, active))
|
||||
.filter_map(|id| doc.layer(id).and_then(|l| l.surface()).map(|surface| (id, surface.clone())))
|
||||
.collect(),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -61,19 +72,19 @@ impl Coverage {
|
||||
/// the original canvas by at least half the pixel's projected size (exact for an unrotated
|
||||
/// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling
|
||||
/// blended with the background colour.
|
||||
fn covered(&self, u: i32, v: i32, margin: f64) -> bool {
|
||||
fn covered(&self, u: i32, v: i32, margin: f64, held: Option<&Surface>) -> bool {
|
||||
let [a, b, c, d, e, f] = self.map;
|
||||
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
|
||||
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
|
||||
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y)
|
||||
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || Self::holds(held, x, y)
|
||||
}
|
||||
|
||||
/// Whether the layer held an image pixel at original document point (x, y) past the canvas
|
||||
/// (#8060): mostly opaque there and one pixel around it too (the turned edge is blended with
|
||||
/// the background colour, as at the canvas edge). Without an alpha channel
|
||||
/// hidden pixels can't be told from empty ones: nothing is held.
|
||||
fn holds(&self, x: f64, y: f64) -> bool {
|
||||
let Some(surf) = &self.held else { return false };
|
||||
fn holds(held: Option<&Surface>, x: f64, y: f64) -> bool {
|
||||
let Some(surf) = held else { return false };
|
||||
let format = surf.format();
|
||||
if !(format.alpha && x.is_finite() && y.is_finite()) {
|
||||
return false;
|
||||
@@ -100,8 +111,20 @@ const UNLABELLED: u32 = u32::MAX;
|
||||
|
||||
/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the
|
||||
/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the
|
||||
/// caller's history step.
|
||||
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage) -> Result<()> {
|
||||
/// caller's history step. Returns whether any uncovered pixels were filled, for the history name.
|
||||
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage, delete: bool) -> Result<bool> {
|
||||
let first = target_layer(doc, active);
|
||||
let additional = if delete { additional_target(doc, active) } else { None };
|
||||
let mut filled = fill_layer(doc, first, cov, false)?;
|
||||
if let Some(id) = additional {
|
||||
filled |= fill_layer(doc, Ok(id), cov, true)?;
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
/// One target at a time keeps the label/window allocations bounded to one canvas. The caller's
|
||||
/// existing edit/compound transaction rolls every target back if a later fill fails.
|
||||
fn fill_layer(doc: &mut Document, id: Result<LayerId>, cov: &Coverage, own_pixels: bool) -> Result<bool> {
|
||||
let canvas = doc.bounds();
|
||||
let (w, h) = (canvas.width(), canvas.height());
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
@@ -110,62 +133,131 @@ pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?;
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
let margin = cov.margin();
|
||||
let held = id.as_ref().ok().and_then(|id| cov.held.as_ref()?.iter().find(|(held_id, _)| held_id == id).map(|(_, surface)| surface));
|
||||
let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?;
|
||||
labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, l) in row.iter_mut().enumerate() {
|
||||
if !cov.covered(x as i32, y as i32, margin) {
|
||||
if !cov.covered(x as i32, y as i32, margin, held) {
|
||||
*l = UNLABELLED;
|
||||
}
|
||||
}
|
||||
});
|
||||
if !labels.contains(&UNLABELLED) {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !labels.contains(&0) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()));
|
||||
return if own_pixels {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()))
|
||||
};
|
||||
}
|
||||
let id = target_layer(doc, active)?;
|
||||
let pieces = label_components(&mut labels, w, h)?;
|
||||
// Resolve an absent target only after the no-fill/sample checks, preserving the legacy path.
|
||||
let id = id?;
|
||||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
let n = surf.format().channels().max(1);
|
||||
let has_alpha = surf.format().alpha;
|
||||
let has_pixel = |x, y| !has_alpha || surf.sample_channel(x, y, n - 1) > 0.0;
|
||||
let donor_bounds = own_pixels.then(|| if has_alpha { surf.content_bounds().intersect(&canvas) } else { canvas });
|
||||
// The added target is optional when it has no covered pixels of its own to sample.
|
||||
// This check does not absorb errors from synthesis or allocation.
|
||||
if donor_bounds.is_some_and(|bounds| {
|
||||
!contains_donor(bounds, |x, y| {
|
||||
let at = usize::try_from(y).ok().and_then(|y| y.checked_mul(w)).zip(usize::try_from(x).ok()).and_then(|(r, x)| r.checked_add(x));
|
||||
at.and_then(|i| labels.get(i)) == Some(&0) && has_pixel(x, y)
|
||||
})
|
||||
}) {
|
||||
return Ok(false);
|
||||
}
|
||||
let pieces = label_components(&mut labels, w, h)?;
|
||||
let label = |x: i32, y: i32| -> u32 {
|
||||
if x < 0 || y < 0 {
|
||||
return 0;
|
||||
}
|
||||
(y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0)
|
||||
};
|
||||
let n = surf.format().channels().max(1);
|
||||
let filled: Vec<(Rect, Vec<f32>)> = pieces
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(k, bounds)| -> Result<(Rect, Vec<f32>)> {
|
||||
let k = k as u32 + 1;
|
||||
let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?;
|
||||
// The piece's bounds, its pixels from the fill and the rest as they were.
|
||||
let mut out = surf.read_region(*bounds);
|
||||
let ww = window.width() as usize;
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
if label(x, y) != k || !window.contains(x, y) {
|
||||
continue;
|
||||
}
|
||||
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
|
||||
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
|
||||
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
let fill_piece = |(k, bounds): (usize, &Rect)| -> Result<(Rect, Vec<f32>)> {
|
||||
let k = k as u32 + 1;
|
||||
// A partial active layer can have no donor near a distant turned corner. Only that
|
||||
// added target may widen its sampling envelope; its hole and output stay unchanged.
|
||||
let usable = |x, y| label(x, y) == 0 && (!own_pixels || has_pixel(x, y));
|
||||
let sampling_bounds = if let Some(donor_bounds) = donor_bounds {
|
||||
let window = crate::fill_cmds::sampling_window(*bounds, canvas).intersect(&donor_bounds);
|
||||
if contains_donor(window, usable) { *bounds } else { widened_sampling_bounds(*bounds, donor_bounds, canvas, n)? }
|
||||
} else {
|
||||
*bounds
|
||||
};
|
||||
let (window, px) = crate::fill_cmds::content_aware_native(
|
||||
surf,
|
||||
sampling_bounds,
|
||||
canvas,
|
||||
true,
|
||||
SEED,
|
||||
|x, y| label(x, y) == k,
|
||||
// A partial active layer (EdgeHalf in #10868) must extend its own image, not
|
||||
// sample its empty canvas rows into the new strip. Keep legacy sampling intact.
|
||||
usable,
|
||||
)?;
|
||||
// The piece's bounds, its pixels from the fill and the rest as they were.
|
||||
let mut out = crate::allocation::read_region(surf, *bounds, "Content-Aware crop output")?;
|
||||
let ww = window.width() as usize;
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
if label(x, y) != k || !window.contains(x, y) {
|
||||
continue;
|
||||
}
|
||||
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
|
||||
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
|
||||
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
Ok((*bounds, out))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
}
|
||||
Ok((*bounds, out))
|
||||
};
|
||||
let filled: Vec<(Rect, Vec<f32>)> = if own_pixels {
|
||||
// A fallback window can span the canvas: keep only one added-target window live.
|
||||
pieces.iter().enumerate().map(fill_piece).collect::<Result<_>>()?
|
||||
} else {
|
||||
pieces.par_iter().enumerate().map(fill_piece).collect::<Result<_>>()?
|
||||
};
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
for (r, px) in filled {
|
||||
surf.write_region(r, &px);
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Checks the candidate rectangle for a covered pixel that passes the target's donor policy.
|
||||
fn contains_donor(bounds: Rect, usable: impl Fn(i32, i32) -> bool) -> bool {
|
||||
(bounds.y0..bounds.y1).any(|y| (bounds.x0..bounds.x1).any(|x| usable(x, y)))
|
||||
}
|
||||
|
||||
/// Widens the sampling envelope to include distant donors, after pricing the actual float read.
|
||||
fn widened_sampling_bounds(piece: Rect, donors: Rect, canvas: Rect, channels: usize) -> Result<Rect> {
|
||||
let expanded = piece.union(&donors);
|
||||
let window = crate::fill_cmds::sampling_window(expanded, canvas);
|
||||
let bytes = (window.width() as usize)
|
||||
.checked_mul(window.height() as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(channels))
|
||||
.and_then(|samples| samples.checked_mul(std::mem::size_of::<f32>()));
|
||||
if bytes.is_none_or(|bytes| bytes > MAX_WIDENED_SAMPLE_BYTES) {
|
||||
return Err(EngineError::Other("the expanded Content-Aware crop sampling window is too large".into()));
|
||||
}
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
/// Only the measured, editable active-raster case adds a target to the Background. Ancestor
|
||||
/// visibility and locks count too; all unmeasured cases keep the original target selection.
|
||||
fn additional_target(doc: &Document, active: Option<LayerId>) -> Option<LayerId> {
|
||||
let background = doc.background()?.id;
|
||||
let id = active.filter(|id| *id != background)?;
|
||||
let path = doc.path_of(id)?;
|
||||
let layer = doc.layer_at(&path)?;
|
||||
(matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) && doc.shown_at(&path) && !doc.locks_at(&path).any()).then_some(id)
|
||||
}
|
||||
|
||||
/// The Background, else the active pixel layer.
|
||||
@@ -242,9 +334,9 @@ mod tests {
|
||||
fn unrotated_coverage_is_exact() {
|
||||
let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4);
|
||||
let m = c.margin();
|
||||
assert!(!c.covered(1, 0, m), "x = -1");
|
||||
assert!(c.covered(2, 0, m), "x = 0");
|
||||
assert!(c.covered(6, 0, m), "x = 4");
|
||||
assert!(!c.covered(7, 0, m), "x = 5");
|
||||
assert!(!c.covered(1, 0, m, None), "x = -1");
|
||||
assert!(c.covered(2, 0, m, None), "x = 0");
|
||||
assert!(c.covered(6, 0, m, None), "x = 4");
|
||||
assert!(!c.covered(7, 0, m, None), "x = 5");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,15 @@ pub struct DocState {
|
||||
/// to `.pcraft` or PSD, and not a history step.
|
||||
pub color_samplers: Vec<[f64; 2]>,
|
||||
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is
|
||||
/// the document as opened. Kept for the session only, as in Photoshop: not history states,
|
||||
/// the document as opened (unless History Options › Automatically Create First Snapshot is
|
||||
/// off). Kept for the session only, as in Photoshop: not history states,
|
||||
/// and not saved with the document.
|
||||
pub snapshots: Vec<history_cmds::Snapshot>,
|
||||
/// The id the next snapshot gets; ids are never reused, so deleting one keeps the others.
|
||||
pub(crate) next_snapshot: u64,
|
||||
/// The snapshot the latest save added (History Options › Automatically Create New Snapshot
|
||||
/// When Saving), so saving an unchanged document again doesn't add a duplicate row.
|
||||
pub(crate) save_snapshot: Option<u64>,
|
||||
/// The snapshot set as the History Brush's source (`history.setSource {snapshot}`); `None`:
|
||||
/// the source is a state, kept by the history ([`photocraft_ops::History::brush_source`]).
|
||||
pub(crate) source_snapshot: Option<u64>,
|
||||
@@ -114,6 +118,7 @@ impl DocState {
|
||||
color_samplers: Vec::new(),
|
||||
snapshots: vec![first],
|
||||
next_snapshot: 1,
|
||||
save_snapshot: None,
|
||||
source_snapshot: None,
|
||||
typed_names: Default::default(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Layer > Smart Objects > Embed Linked and Embed All Linked (#11401).
|
||||
|
||||
use photocraft_doc::{LayerContent, SmartSource};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::command_specs;
|
||||
|
||||
fn label(id: &str) -> &'static str {
|
||||
command_specs().iter().find(|c| c.id == id).map(|c| c.label).unwrap_or_else(|| panic!("{id} is not registered"))
|
||||
}
|
||||
|
||||
/// Adds a layer with a few painted pixels (an empty layer can't become a smart object).
|
||||
fn painted_layer(s: &mut Session, red: f32) {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("setup", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
surf.fill_rect(photocraft_geom::Rect::new(2, 2, 10, 10), &[red, 1.0 - red, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Three smart objects: the first two linked to files in `dir`, the third embedded.
|
||||
fn three(dir: &std::path::Path) -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 24})).unwrap();
|
||||
let mut ids = [0u64; 3];
|
||||
for (i, slot) in ids.iter_mut().enumerate() {
|
||||
painted_layer(&mut s, 0.25 * (i + 1) as f32);
|
||||
*slot = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
if i < 2 {
|
||||
let path = dir.join(format!("linked{i}.pcraft"));
|
||||
s.execute("layer.smartObjects.convertToLinked", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
}
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn is_linked(s: &Session, id: u64) -> bool {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap();
|
||||
match &layer.content {
|
||||
LayerContent::Smart(sm) => matches!(sm.source, SmartSource::Linked { .. }),
|
||||
other => panic!("not smart: {}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name and bytes an embedded smart object carries.
|
||||
fn embedded(s: &Session, id: u64) -> (String, Vec<u8>) {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap();
|
||||
match &layer.content {
|
||||
LayerContent::Smart(sm) => match &sm.source {
|
||||
SmartSource::Embedded { file_name, bytes } => (file_name.clone(), bytes.to_vec()),
|
||||
SmartSource::Linked { .. } => panic!("still linked"),
|
||||
},
|
||||
other => panic!("not smart: {}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pc-embed-all-{name}-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_linked_and_embed_all_linked_are_named_in_the_engine() {
|
||||
assert_eq!(label("layer.smartObjects.convertToEmbedded"), "Embed Linked");
|
||||
assert_eq!(label("layer.smartObjects.embedAllLinked"), "Embed All Linked");
|
||||
// The warp commands stay registered for scripts and the Edit menu routes.
|
||||
for id in ["layer.smartObjects.warp", "layer.smartObjects.perspectiveWarp", "layer.smartObjects.puppetWarp"] {
|
||||
label(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_all_linked_embeds_both_linked_objects_in_one_undoable_step() {
|
||||
let dir = temp_dir("both");
|
||||
let (mut s, ids) = three(&dir);
|
||||
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2]));
|
||||
assert!(s.is_enabled("layer.smartObjects.embedAllLinked"));
|
||||
let before = s.active().unwrap().history.entries().len();
|
||||
let files: Vec<Vec<u8>> = (0..2).map(|i| photocraft_format::read_file(&dir.join(format!("linked{i}.pcraft"))).unwrap()).collect();
|
||||
assert_ne!(files[0], files[1], "the fixtures differ");
|
||||
let third = embedded(&s, ids[2]);
|
||||
|
||||
let r = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap();
|
||||
assert_eq!(r["embedded"], json!([ids[0], ids[1]]));
|
||||
assert!(ids.iter().all(|&id| !is_linked(&s, id)));
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let (name, bytes) = embedded(&s, ids[i]);
|
||||
assert_eq!(name, format!("linked{i}.pcraft"), "object {i} keeps its own file name");
|
||||
assert_eq!(&bytes, file, "object {i} carries its own linked file's bytes");
|
||||
}
|
||||
assert_eq!(embedded(&s, ids[2]), third, "the already embedded object is untouched");
|
||||
let entries = s.active().unwrap().history.entries();
|
||||
assert_eq!(entries.len(), before + 1, "one history step");
|
||||
assert_eq!(entries.last().map(String::as_str), Some("Embed All Linked"));
|
||||
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "nothing left to embed");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2]));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), before);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_all_linked_with_no_linked_object_is_an_error_not_a_panic() {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "no document");
|
||||
assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err());
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
painted_layer(&mut s, 1.0);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "only embedded objects");
|
||||
let before = s.active().unwrap().history.entries().len();
|
||||
assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err());
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_all_linked_refuses_the_whole_step_when_a_linked_file_is_missing() {
|
||||
let dir = temp_dir("missing");
|
||||
let (mut s, ids) = three(&dir);
|
||||
std::fs::remove_file(dir.join("linked1.pcraft")).unwrap();
|
||||
let before = s.active().unwrap().history.entries().len();
|
||||
let err = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("linked1.pcraft"), "the error names the missing file: {err}");
|
||||
assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]), "nothing was embedded");
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), before);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_linked_still_embeds_one_object_under_its_old_id() {
|
||||
let dir = temp_dir("one");
|
||||
let (mut s, ids) = three(&dir);
|
||||
s.execute("layer.smartObjects.convertToEmbedded", json!({"layer": ids[0]})).unwrap();
|
||||
assert!(!is_linked(&s, ids[0]) && is_linked(&s, ids[1]));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -332,11 +332,15 @@ pub struct Session {
|
||||
pub damage_misses: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod embed_all_linked_tests;
|
||||
#[cfg(test)]
|
||||
mod fill_layer_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod second_keys_zoom_actions_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1119,21 +1119,36 @@ impl Actions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either).
|
||||
/// Photoshop's snapshot options come with #3634.
|
||||
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either),
|
||||
/// in the dialog's order.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HistoryOptions {
|
||||
/// Automatically Create First Snapshot: a new or opened document starts with a snapshot of
|
||||
/// itself, named after it (the History panel's top row).
|
||||
pub automatically_create_first_snapshot: bool,
|
||||
/// Automatically Create New Snapshot When Saving: every save of the document (not a copy)
|
||||
/// adds a snapshot named with the time of the save.
|
||||
pub automatically_create_new_snapshot_when_saving: bool,
|
||||
/// Allow Non-Linear History: editing after selecting an earlier state keeps the later
|
||||
/// states (the new step is appended at the end), and deleting a state deletes only it.
|
||||
pub allow_non_linear_history: bool,
|
||||
/// Show New Snapshot Dialog by Default: a plain click on the History panel's camera button
|
||||
/// opens New Snapshot (otherwise only ⌥-click does).
|
||||
pub show_new_snapshot_dialog_by_default: bool,
|
||||
/// Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state.
|
||||
pub make_layer_visibility_changes_undoable: bool,
|
||||
}
|
||||
|
||||
impl Default for HistoryOptions {
|
||||
fn default() -> Self {
|
||||
Self { allow_non_linear_history: false, make_layer_visibility_changes_undoable: true }
|
||||
Self {
|
||||
automatically_create_first_snapshot: true,
|
||||
automatically_create_new_snapshot_when_saving: false,
|
||||
allow_non_linear_history: false,
|
||||
show_new_snapshot_dialog_by_default: false,
|
||||
make_layer_visibility_changes_undoable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
section!("edit.preferences.transparencyAndGamut", "Transparency & Gamut…"),
|
||||
section!("edit.preferences.unitsAndRulers", "Units & Rulers…"),
|
||||
section!("edit.preferences.guidesGridAndSlices", "Guides, Grid & Slices…"),
|
||||
section!("edit.preferences.plugIns", "Plug-ins…"),
|
||||
section!("edit.preferences.plugIns", "Plugins…"),
|
||||
section!("edit.preferences.type", "Type…"),
|
||||
section!("edit.preferences.enhancedControls", "Enhanced Controls…"),
|
||||
section!("edit.preferences.rawDefaults", "Camera Raw…"),
|
||||
|
||||
@@ -124,6 +124,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
|
||||
/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace
|
||||
/// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside
|
||||
/// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus).
|
||||
/// Also Alt+Ctrl+0 beside Ctrl+1 for 100% and F9 beside Alt+F9 for Window > Actions (#11396;
|
||||
/// measured on Windows, so the Mac gets the usual Cmd mapping of the first).
|
||||
pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("edit.cut", "F2"),
|
||||
("edit.copy", "F3"),
|
||||
@@ -131,6 +133,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("edit.fill", "Shift+Backspace"),
|
||||
("select.inverse", "Shift+F7"),
|
||||
("view.zoomIn", "Cmd++"),
|
||||
("view.actualPixels", "Cmd+Alt+0"),
|
||||
("window.panel.actions", "F9"),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
("file.close", "Cmd+F4"),
|
||||
];
|
||||
|
||||
@@ -21,7 +21,7 @@ pub const SECTIONS: [(&str, &str); 18] = [
|
||||
("transparencyAndGamut", "Transparency & Gamut"),
|
||||
("unitsAndRulers", "Units & Rulers"),
|
||||
("guidesGridAndSlices", "Guides, Grid & Slices"),
|
||||
("plugIns", "Plug-ins"),
|
||||
("plugIns", "Plugins"),
|
||||
("type", "Type"),
|
||||
("enhancedControls", "Enhanced Controls"),
|
||||
("rawDefaults", "Camera Raw Defaults"),
|
||||
|
||||
@@ -305,8 +305,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
kind_spec!("view.proofSetup.workingMagentaPlate", "Working Magenta Plate"),
|
||||
kind_spec!("view.proofSetup.workingYellowPlate", "Working Yellow Plate"),
|
||||
kind_spec!("view.proofSetup.workingBlackPlate", "Working Black Plate"),
|
||||
kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plate"),
|
||||
kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB"),
|
||||
kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plates"),
|
||||
kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB (Gamma 1.8)"),
|
||||
kind_spec!("view.proofSetup.colorBlindnessProtanopia", "Color Blindness — Protanopia-type"),
|
||||
kind_spec!("view.proofSetup.colorBlindnessDeuteranopia", "Color Blindness — Deuteranopia-type"),
|
||||
CommandSpec {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The reference application's second default keys for View > 100% and Window > Actions (#11396):
|
||||
//! Alt+Ctrl+0 beside Ctrl+1, and F9 beside Alt+F9.
|
||||
//!
|
||||
//! The engine owns the key tables and the Keyboard Shortcuts command. The zoom itself and the
|
||||
//! Actions panel are UI state (`window.panel.actions` is a menu item the engine does not run), so
|
||||
//! these tests check that each key resolves to its command exactly as the main key does and
|
||||
//! that the user can remove it. They do not exercise key dispatch; `alternate_keys_run_their_command`
|
||||
//! in the UI crate's `shortcut_dispatch` tests does, and lists both keys.
|
||||
|
||||
use crate::Session;
|
||||
use crate::prefs::{ALTERNATE_SHORTCUTS, default_alternates, normalize_shortcut};
|
||||
use serde_json::json;
|
||||
|
||||
/// The menus' main keys (`menu_catalog.rs`): what View > 100% and Window > Actions show.
|
||||
const ACTUAL_PIXELS_MAIN: &str = "Cmd+1";
|
||||
const ACTIONS_MAIN: &str = "Alt+F9";
|
||||
|
||||
/// Our notation: `Cmd` is Ctrl off the Mac, so this is Ctrl+Alt+0 on Windows and Linux.
|
||||
const ACTUAL_PIXELS_ALT: &str = "Cmd+Alt+0";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The command a key press runs among the engine's bindable commands and the two this issue
|
||||
/// adds keys to: a command's main key (given for the UI-only one), else its alternates in effect.
|
||||
fn resolve(s: &Session, key: &str) -> Vec<&'static str> {
|
||||
let want = normalize_shortcut(key).unwrap();
|
||||
let mains = [("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), ("window.panel.actions", Some(ACTIONS_MAIN))];
|
||||
let mut ids: Vec<&'static str> = Vec::new();
|
||||
for (id, main) in mains {
|
||||
let main_in_effect = s.prefs().shortcut(id, main);
|
||||
let is_main = main_in_effect.and_then(normalize_shortcut).as_ref() == Some(&want);
|
||||
if is_main || s.prefs().alternates(id, main_in_effect).contains(&want) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_keys_are_default_alternates() {
|
||||
assert!(ALTERNATE_SHORTCUTS.contains(&("view.actualPixels", "Cmd+Alt+0")));
|
||||
assert!(ALTERNATE_SHORTCUTS.contains(&("window.panel.actions", "F9")));
|
||||
assert_eq!(default_alternates("view.actualPixels").collect::<Vec<_>>(), ["Cmd+Alt+0"]);
|
||||
assert_eq!(default_alternates("window.panel.actions").collect::<Vec<_>>(), ["F9"]);
|
||||
let s = session();
|
||||
let zoom = s.prefs().alternates("view.actualPixels", Some(ACTUAL_PIXELS_MAIN));
|
||||
assert_eq!(zoom, [normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()]);
|
||||
assert_eq!(s.prefs().alternates("window.panel.actions", Some(ACTIONS_MAIN)), ["F9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_key_resolves_to_its_command_like_the_main_key() {
|
||||
let s = session();
|
||||
assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]);
|
||||
assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"]);
|
||||
assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]);
|
||||
assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_shows_only_the_main_key() {
|
||||
// The menus print the catalogue's key, and an alternate is never the main key in effect.
|
||||
let s = session();
|
||||
assert_eq!(s.prefs().shortcut("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), Some(ACTUAL_PIXELS_MAIN));
|
||||
assert_eq!(s.prefs().shortcut("window.panel.actions", Some(ACTIONS_MAIN)), Some(ACTIONS_MAIN));
|
||||
assert_eq!(crate::shell_commands::SHELL_COMMANDS.iter().find(|c| c.0 == "view.actualPixels").and_then(|c| c.3), Some(ACTUAL_PIXELS_MAIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_shortcuts_lists_the_second_key_and_removing_it_stops_it() {
|
||||
let mut s = session();
|
||||
let r = s.execute("edit.keyboardShortcuts", json!({"filter": "view.actualPixels"})).unwrap();
|
||||
let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "view.actualPixels").unwrap().clone();
|
||||
assert_eq!(row["shortcut"], ACTUAL_PIXELS_MAIN);
|
||||
assert_eq!(row["alternates"], json!([normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()]));
|
||||
assert_eq!(row["defaultAlternates"], json!(["Cmd+Alt+0"]));
|
||||
|
||||
s.execute("edit.keyboardShortcuts", json!({"alternates": {"view.actualPixels": []}})).unwrap();
|
||||
assert!(resolve(&s, ACTUAL_PIXELS_ALT).is_empty());
|
||||
assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"], "the main key still works");
|
||||
|
||||
// Window > Actions is a menu item, so it is not in the engine's list, but its second key can
|
||||
// still be set and removed, as the Keyboard Shortcuts dialog does.
|
||||
s.execute("edit.keyboardShortcuts", json!({"alternates": {"window.panel.actions": []}})).unwrap();
|
||||
assert!(resolve(&s, "F9").is_empty());
|
||||
assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]);
|
||||
|
||||
s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap();
|
||||
assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]);
|
||||
assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]);
|
||||
}
|
||||
@@ -142,6 +142,11 @@ impl Session {
|
||||
st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize;
|
||||
st.history.max_bytes = self.prefs.get().performance.history_cap_bytes();
|
||||
st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history;
|
||||
// History Options › Automatically Create First Snapshot off: no snapshot of the document
|
||||
// as opened, so the History panel starts at the opening state (#2966).
|
||||
if !self.prefs.get().history_options.automatically_create_first_snapshot {
|
||||
st.snapshots.clear();
|
||||
}
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
|
||||
@@ -42,7 +42,9 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
|
||||
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
|
||||
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
|
||||
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
|
||||
("view.lockGuides", "Lock Guides", &["View", "Guides"], Some("Cmd+Alt+;")),
|
||||
("view.editSelectedGuides", "Edit Selected Guides", &["View", "Guides"], None),
|
||||
("view.clearSelectedGuides", "Clear Selected Guides", &["View", "Guides"], None),
|
||||
("view.extras", "Extras", &["View"], Some("Cmd+H")),
|
||||
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
|
||||
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
|
||||
@@ -57,7 +59,7 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
|
||||
("view.rotateView", "Rotate View", &[], None),
|
||||
("view.resetView", "Reset View", &["View"], None),
|
||||
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
|
||||
("window.newWindowForDocument", "New Window", &["Window", "Arrange"], None),
|
||||
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
|
||||
("window.toggle.history", "History", &["Window"], None),
|
||||
("window.toggle.properties", "Properties", &["Window"], None),
|
||||
@@ -122,8 +124,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.count", "Count Tool", Some("I")),
|
||||
("tools.select.brush", "Brush Tool", Some("B")),
|
||||
("tools.select.pencil", "Pencil Tool", Some("B")),
|
||||
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
|
||||
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
|
||||
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
|
||||
("tools.select.eraser", "Eraser Tool", Some("E")),
|
||||
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
|
||||
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
|
||||
@@ -136,8 +138,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.hand", "Hand Tool", Some("H")),
|
||||
("tools.select.rotateView", "Rotate View Tool", Some("R")),
|
||||
("tools.select.zoom", "Zoom Tool", Some("Z")),
|
||||
("tools.select.remove", "Remove Tool", Some("J")),
|
||||
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
|
||||
("tools.select.remove", "Remove Tool", Some("J")),
|
||||
("tools.select.healing", "Healing Brush Tool", Some("J")),
|
||||
("tools.select.patch", "Patch Tool", Some("J")),
|
||||
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
|
||||
@@ -157,9 +159,9 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.pen", "Pen Tool", Some("P")),
|
||||
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
|
||||
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
|
||||
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
|
||||
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
|
||||
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
|
||||
("tools.select.addAnchorPoint", "Add Anchor Point Tool", None),
|
||||
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", None),
|
||||
("tools.select.convertPoint", "Convert Point Tool", None),
|
||||
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
|
||||
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
|
||||
("tools.select.rectangle", "Rectangle Tool", Some("U")),
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The measured Perspective Warp descriptor shape through ATN conversion and action playback.
|
||||
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const EVENT: &str = "perspectiveWarpTransform";
|
||||
const COMMAND: &str = "edit.perspectiveWarp";
|
||||
|
||||
fn px(value: f64) -> D {
|
||||
D::UnitFloat { unit: *b"#Pxl", value }
|
||||
}
|
||||
|
||||
fn points(points: &[[f64; 2]]) -> D {
|
||||
D::List(points.iter().map(|&[x, y]| D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(x)).with("Vrtc", px(y)))).collect())
|
||||
}
|
||||
|
||||
fn quad(indices: [i32; 4]) -> D {
|
||||
D::Descriptor(Descriptor::new("perspectiveWarpQuad").with("indices", D::List(indices.into_iter().map(D::Integer).collect())))
|
||||
}
|
||||
|
||||
/// Two planes sharing a seam. Vertices appear out of corner order to distinguish exact export.
|
||||
fn fixture(name: Option<&str>) -> Descriptor {
|
||||
let class = Class { class_id: Id::new("Lyr "), name: UnicodeString::new_nul("") };
|
||||
let target = match name {
|
||||
Some(name) => ReferenceItem::Name { class, name: UnicodeString::new_nul(name) },
|
||||
None => ReferenceItem::Enumerated { class, type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
};
|
||||
let rect = Descriptor::new("Rctn").with("Top ", px(0.0)).with("Left", px(0.0)).with("Btom", px(24.0)).with("Rght", px(32.0));
|
||||
Descriptor::new(EVENT)
|
||||
.with("null", D::Reference(vec![target]))
|
||||
.with("Md ", D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("warp") })
|
||||
.with("referenceRect", D::Descriptor(rect))
|
||||
.with("vertices", points(&[[16.0, 20.0], [4.0, 4.0], [28.0, 4.0], [4.0, 20.0], [16.0, 4.0], [28.0, 20.0]]))
|
||||
.with("warpedVertices", points(&[[17.0, 20.0], [5.5, 5.0], [27.0, 6.0], [4.0, 19.0], [18.0, 4.0], [26.0, 17.0]]))
|
||||
.with("quads", D::List(vec![quad([1, 4, 0, 3]), quad([4, 2, 5, 0])]))
|
||||
}
|
||||
|
||||
fn imported(d: Descriptor, with_dialog: bool, enabled: bool) -> atn_cmds::Imported {
|
||||
let item =
|
||||
AtnItem { event: AtnEvent::name(EVENT), dictionary: b"Perspective Warp".to_vec(), descriptor: Some(d), with_dialog, enabled, ..Default::default() };
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Set"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Warp"), items: vec![item], ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
atn_cmds::import(&atn::write(&file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn saved(action: &Action) -> AtnItem {
|
||||
let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
atn::parse(&bytes).unwrap().actions[0].items[0].clone()
|
||||
}
|
||||
|
||||
fn planes() -> Value {
|
||||
json!([
|
||||
{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5.5,5],[18,4],[17,20],[4,19]]},
|
||||
{"src":[[16,4],[28,4],[28,20],[16,20]],"dst":[[18,4],[27,6],[26,17],[17,20]]}
|
||||
])
|
||||
}
|
||||
|
||||
fn value_mut<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut D {
|
||||
&mut d.items.iter_mut().find(|(id, _)| id.is(key)).unwrap().1
|
||||
}
|
||||
|
||||
/// Exact export preserves the original indexed mesh, target, rectangle and step flags.
|
||||
#[test]
|
||||
fn descriptor_round_trip_preserves_shared_indices_and_flags() {
|
||||
for name in [None, Some("Façade")] {
|
||||
let d = fixture(name);
|
||||
let loaded = imported(d.clone(), true, false);
|
||||
assert!(loaded.unsupported.is_empty());
|
||||
let action = &loaded.actions[0];
|
||||
assert_eq!(action.steps[0].0, COMMAND);
|
||||
let params = &action.steps[0].1;
|
||||
assert_eq!(params["planes"], planes());
|
||||
assert_eq!(params["layer"].as_str(), name);
|
||||
assert_eq!(params["target"], "pixels");
|
||||
assert_eq!(params["__atnPerspectiveWarpRect"], json!([0, 0, 32, 24]));
|
||||
let item = saved(action);
|
||||
assert_eq!(item.event.id(), EVENT);
|
||||
assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes());
|
||||
assert!(item.with_dialog);
|
||||
assert!(!item.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsupported targets, units, modes and geometry retain the original bytes as kept steps.
|
||||
#[test]
|
||||
fn unsupported_recordings_are_kept_without_partial_mapping() {
|
||||
let d = fixture(None);
|
||||
let mut layout = d.clone();
|
||||
*value_mut(&mut layout, "Md ") = D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("layout") };
|
||||
let mut wrong_target = d.clone();
|
||||
*value_mut(&mut wrong_target, "null") = D::Reference(vec![ReferenceItem::Class(Class { class_id: Id::new("Chnl"), name: UnicodeString::new_nul("") })]);
|
||||
let mut bad_index = d.clone();
|
||||
*value_mut(&mut bad_index, "quads") = D::List(vec![quad([1, 4, 99, 3])]);
|
||||
let mut wrong_units = d.clone();
|
||||
let D::Descriptor(rect) = value_mut(&mut wrong_units, "referenceRect") else { panic!("reference rectangle") };
|
||||
*value_mut(rect, "Rght") = D::UnitFloat { unit: *b"#Prc", value: 32.0 };
|
||||
let mut incompatible_topology = d.clone();
|
||||
for key in ["vertices", "warpedVertices"] {
|
||||
let D::List(points) = value_mut(&mut incompatible_topology, key) else { panic!("vertex list") };
|
||||
points.push(points[4].clone());
|
||||
}
|
||||
*value_mut(&mut incompatible_topology, "quads") = D::List(vec![quad([1, 4, 0, 3]), quad([6, 2, 5, 0])]);
|
||||
let mut duplicate = d.clone();
|
||||
duplicate.items.push((Id::new("quads"), d.get("quads").unwrap().clone()));
|
||||
for descriptor in [layout, wrong_target, bad_index, wrong_units, incompatible_topology, duplicate] {
|
||||
let loaded = imported(descriptor.clone(), false, true);
|
||||
assert_eq!(loaded.unsupported.len(), 1, "{descriptor:?}");
|
||||
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), descriptor.to_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Native export needs a reference rectangle. A stale recorded topology must not be substituted.
|
||||
#[test]
|
||||
fn native_export_requires_known_rectangle_and_rejects_stale_source() {
|
||||
let mut action = Action::new("Warp", vec![(COMMAND.into(), json!({"planes": planes()}))]);
|
||||
assert!(atn_cmds::export("Set", &[&action], &[], false).is_err());
|
||||
action.steps[0].1["__atnPerspectiveWarpRect"] = json!([0, 0, 32, 24]);
|
||||
let d = saved(&action).descriptor.unwrap();
|
||||
let keys: Vec<_> = d.items.iter().map(|(id, _)| id.as_bytes()).collect();
|
||||
assert_eq!(keys, [b"null".as_slice(), b"Md ", b"referenceRect", b"vertices", b"warpedVertices", b"quads"]);
|
||||
let mut recorded = imported(d, false, true).actions.remove(0);
|
||||
recorded.steps[0].1["planes"][0]["dst"][0][0] = json!(6);
|
||||
assert!(atn_cmds::export("Set", &[&recorded], &[], false).is_err());
|
||||
action.steps[0].1["interpolation"] = json!("nearest");
|
||||
assert!(atn_cmds::export("Set", &[&action], &[], false).is_err());
|
||||
}
|
||||
|
||||
fn session(depth: u8) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("Fixture", |doc, _| {
|
||||
let layer = doc.layer_mut(id).unwrap();
|
||||
layer.name = "Facade".into();
|
||||
let surf = layer.surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
surf.fill_rect(Rect::new(4, 4, 16, 20), &photocraft_raster::from_rgba(&fmt, [0.9, 0.2, 0.1, 1.0]));
|
||||
surf.fill_rect(Rect::new(16, 4, 28, 20), &photocraft_raster::from_rgba(&fmt, [0.1, 0.2, 0.9, 1.0]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// A named raster is warped, while the active decoy stays unchanged.
|
||||
#[test]
|
||||
fn action_playback_matches_the_native_raster_warp() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut actual, id) = session(depth);
|
||||
let (mut direct, did) = session(depth);
|
||||
let before = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
for s in [&mut actual, &mut direct] {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
}
|
||||
let decoy = actual.active().unwrap().active_layer.unwrap();
|
||||
let decoy_before = actual.active().unwrap().doc.layer(decoy).unwrap().clone();
|
||||
actual.actions.list.extend(imported(fixture(Some("Facade")), false, true).actions);
|
||||
actual.execute("actions.play", json!({"action":0})).unwrap();
|
||||
direct.execute(COMMAND, json!({"planes":planes(),"layer":"Facade","target":"pixels"})).unwrap();
|
||||
let result = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
|
||||
assert_ne!(*result, before);
|
||||
assert_eq!(result, direct.active().unwrap().doc.layer(did).unwrap().surface().unwrap());
|
||||
assert_eq!(actual.active().unwrap().doc.layer(decoy), Some(&decoy_before));
|
||||
actual.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(actual.active().unwrap().doc.layer(id).unwrap().surface(), Some(&before));
|
||||
}
|
||||
}
|
||||
|
||||
/// The same active-layer recording adds a native Smart Filter and keeps PSD export available.
|
||||
#[test]
|
||||
fn active_smart_object_playback_matches_the_native_filter() {
|
||||
let (mut actual, _) = session(8);
|
||||
let (mut direct, _) = session(8);
|
||||
for s in [&mut actual, &mut direct] {
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
}
|
||||
actual.actions.list.extend(imported(fixture(None), false, true).actions);
|
||||
actual.execute("actions.play", json!({"action":0})).unwrap();
|
||||
direct.execute("layer.smartObjects.perspectiveWarp", json!({"planes":planes()})).unwrap();
|
||||
let [actual, direct] = [&actual, &direct].map(|s| {
|
||||
let st = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart object") };
|
||||
sm
|
||||
});
|
||||
assert_eq!(actual.cache, direct.cache);
|
||||
assert_eq!(actual.smart_filters.len(), 1);
|
||||
assert_eq!(actual.smart_filters[0].command, COMMAND);
|
||||
assert_eq!(actual.smart_filters[0].params["planes"], planes());
|
||||
assert!(photocraft_io::smart_map::item_for_filter(&actual.smart_filters[0]).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! The measured Smart Object Stack Mode events retain their descriptor and native behavior.
|
||||
|
||||
use photocraft_doc::{Group, Layer, LayerContent, StackMode};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Class, Descriptor, Id, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
const APPLY: &str = "applyImageStackPluginRenderer";
|
||||
const CLEAR: &str = "imageStackConvertSmartObject";
|
||||
const ORIGIN: &str = "_atnStackDescriptor";
|
||||
|
||||
/// Independent pairs from the measured table in docs/atn.md.
|
||||
const MODES: &[(&str, &str, StackMode)] = &[
|
||||
("rang", "Range", StackMode::Range),
|
||||
("entr", "Entropy", StackMode::Entropy),
|
||||
("kurt", "Kurtosis", StackMode::Kurtosis),
|
||||
("maxx", "Maximum", StackMode::Maximum),
|
||||
("medn", "Median", StackMode::Median),
|
||||
("minn", "Minimum", StackMode::Minimum),
|
||||
("avrg", "Mean", StackMode::Mean),
|
||||
("skew", "Skewness", StackMode::Skewness),
|
||||
("stdv", "Standard Deviation", StackMode::StandardDeviation),
|
||||
("summ", "Summation", StackMode::Summation),
|
||||
("vari", "Variance", StackMode::Variance),
|
||||
];
|
||||
|
||||
fn descriptor(code: &str, name: &str) -> Descriptor {
|
||||
Descriptor::new("null")
|
||||
.with("imageStackPlugin", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(code) }))
|
||||
.with("Nm ", D::Text(UnicodeString::new_nul(name)))
|
||||
}
|
||||
|
||||
fn item(event: &str, descriptor: Option<Descriptor>) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() }
|
||||
}
|
||||
|
||||
fn load(item: AtnItem) -> atn_cmds::Imported {
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Stacks"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Mode"), items: vec![item], ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
atn_cmds::import(&atn::write(&file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn saved(action: &Action) -> AtnItem {
|
||||
let (bytes, skipped) = atn_cmds::export("Stacks", &[action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0)
|
||||
}
|
||||
|
||||
/// Exercise actual load/save bytes for all plugin classes and the separate None event.
|
||||
#[test]
|
||||
fn measured_stack_modes_and_none_import_export_and_reload() {
|
||||
for &(code, name, mode) in MODES {
|
||||
let original = item(APPLY, Some(descriptor(code, name)));
|
||||
let imported = load(original.clone());
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(imported.actions[0].steps, vec![(format!("layer.smartObjects.stackMode.{}", mode.id()), json!({}))]);
|
||||
let out = saved(&imported.actions[0]);
|
||||
assert_eq!(out.event, original.event);
|
||||
assert_eq!(out.descriptor, original.descriptor);
|
||||
assert_eq!(load(out).actions[0].steps, imported.actions[0].steps);
|
||||
}
|
||||
for original in [item(CLEAR, None), item(CLEAR, Some(Descriptor::new("null")))] {
|
||||
let imported = load(original.clone());
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.none");
|
||||
let out = saved(&imported.actions[0]);
|
||||
assert_eq!(out.event, original.event);
|
||||
assert_eq!(out.descriptor, original.descriptor);
|
||||
assert_eq!(load(out).actions[0].steps, imported.actions[0].steps);
|
||||
}
|
||||
let native = Action::new("Mode", vec![("layer.smartObjects.stackMode.none".into(), json!({}))]);
|
||||
let out = saved(&native);
|
||||
assert_eq!(out.event, AtnEvent::name(CLEAR));
|
||||
assert!(out.descriptor.is_none());
|
||||
}
|
||||
|
||||
/// Localized names and noncanonical wire metadata still replay the class-selected mode.
|
||||
#[test]
|
||||
fn localized_stack_descriptors_keep_original_metadata_and_flags() {
|
||||
for name in [UnicodeString::new("Moyenne"), UnicodeString::new_nul("平均值"), UnicodeString(vec![0xd800, 0, 0])] {
|
||||
let mut d = descriptor("avrg", "Mean");
|
||||
d.name = UnicodeString::new_nul("Recorded stack");
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
d.items[1].1 = D::Text(name);
|
||||
d.items[1].0 = Id::Str(b"Nm ".to_vec());
|
||||
d.items.reverse();
|
||||
let mut original = item(APPLY, Some(d));
|
||||
original.enabled = false;
|
||||
original.with_dialog = true;
|
||||
original.dialog_options = 2;
|
||||
let imported = load(original.clone());
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.mean");
|
||||
let out = saved(&imported.actions[0]);
|
||||
assert_eq!(out.descriptor, original.descriptor);
|
||||
assert!(!out.enabled);
|
||||
assert!(out.with_dialog);
|
||||
assert_eq!(out.dialog_options, 2);
|
||||
}
|
||||
let mut d = Descriptor::new("null");
|
||||
d.name = UnicodeString(vec![0xd800, 0]);
|
||||
d.class_id = Id::Str(b"null".to_vec());
|
||||
let imported = load(item(CLEAR, Some(d.clone())));
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(saved(&imported.actions[0]).descriptor, Some(d));
|
||||
}
|
||||
|
||||
fn stack(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":3,"height":2,"depth":depth})).unwrap();
|
||||
s.edit("Fixture", |doc, active| {
|
||||
let fmt = doc.pixel_format();
|
||||
let children = [0.2, 0.2, 0.8]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
let mut layer = Layer::raster(format!("Frame {index}"), fmt);
|
||||
let pixel = photocraft_raster::from_rgba(&fmt, [value, value, value, 1.0]);
|
||||
layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 2), &pixel.repeat(6));
|
||||
layer
|
||||
})
|
||||
.collect();
|
||||
let group = Layer::new("Stack", LayerContent::Group(Group { children, expanded: true, artboard: None, frame_stroke: None }));
|
||||
let smart = photocraft_engine::smart_cmds::layer_to_smart(doc, &group)?;
|
||||
*active = Some(smart.id);
|
||||
doc.layers = vec![smart];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Compare the adapter with existing native commands, including pixel output and one Undo/Redo.
|
||||
#[test]
|
||||
fn stack_recordings_play_native_modes_and_undo_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = stack(depth);
|
||||
let modes = MODES.iter().map(|&(code, _, mode)| (item(APPLY, Some(descriptor(code, "Recorded name"))), Some(mode)));
|
||||
let clear_forms = [None, Some(Descriptor::new("null"))].into_iter().map(|d| (item(CLEAR, d), None));
|
||||
for (source, mode) in modes.chain(clear_forms) {
|
||||
let starting = if mode == Some(StackMode::Maximum) { "none" } else { "maximum" };
|
||||
s.execute(&format!("layer.smartObjects.stackMode.{starting}"), json!({})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let before_steps = s.active().unwrap().history.past_len();
|
||||
let command = format!("layer.smartObjects.stackMode.{}", mode.map_or("none", StackMode::id));
|
||||
s.execute(&command, json!({})).unwrap();
|
||||
let expected = s.active().unwrap().doc.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let imported = load(source);
|
||||
s.actions.list = imported.actions;
|
||||
let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap();
|
||||
assert_eq!(result["ran"], 1, "{result}");
|
||||
let actual = s.active().unwrap();
|
||||
assert_eq!(actual.doc.layers, expected.layers, "{command} at {depth}-bit");
|
||||
assert_eq!(actual.history.past_len(), before_steps + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers, before.layers);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers, expected.layers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrepresented types, duplicate keys and extra settings stay complete kept steps.
|
||||
#[test]
|
||||
fn unsupported_stack_recordings_save_whole_and_refuse_playback() {
|
||||
let canonical = descriptor("medn", "Median");
|
||||
let mut duplicate = canonical.clone();
|
||||
duplicate.items.push(duplicate.items[0].clone());
|
||||
let mut wrong_name = canonical.clone();
|
||||
wrong_name.items[1].1 = D::Integer(5);
|
||||
let mut global_class = canonical.clone();
|
||||
let D::Class(class) = global_class.items[0].1.clone() else { unreachable!() };
|
||||
global_class.items[0].1 = D::GlobalClass(class);
|
||||
let extra = canonical.clone().with("unknown", D::Boolean(true));
|
||||
for original in [
|
||||
item(APPLY, Some(duplicate)),
|
||||
item(APPLY, Some(wrong_name)),
|
||||
item(APPLY, Some(global_class)),
|
||||
item(APPLY, Some(extra)),
|
||||
item(APPLY, Some(descriptor("nope", "Median"))),
|
||||
item(APPLY, None),
|
||||
item(CLEAR, Some(canonical)),
|
||||
item(CLEAR, Some(Descriptor::new("nope"))),
|
||||
] {
|
||||
let imported = load(original.clone());
|
||||
assert_eq!(imported.unsupported.len(), 1);
|
||||
assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP);
|
||||
let out = saved(&imported.actions[0]);
|
||||
assert_eq!(out.event, original.event);
|
||||
assert_eq!(out.descriptor, original.descriptor);
|
||||
let mut s = stack(8);
|
||||
s.execute("layer.smartObjects.stackMode.maximum", json!({})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
s.actions.list = imported.actions;
|
||||
let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap();
|
||||
assert!(result.get("failed").is_some(), "{result}");
|
||||
assert_eq!(s.active().unwrap().doc.layers, before.layers);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
/// Native mode edits replace the old class; explicit targets and damaged origin metadata refuse export.
|
||||
#[test]
|
||||
fn stack_mode_edits_and_unrepresented_native_parameters_are_safe() {
|
||||
let imported = load(item(APPLY, Some(descriptor("medn", "Médiane"))));
|
||||
let mut action = imported.actions[0].clone();
|
||||
action.steps[0].0 = "layer.smartObjects.stackMode.maximum".into();
|
||||
assert_eq!(saved(&action).descriptor, Some(descriptor("maxx", "Maximum")));
|
||||
|
||||
action.steps[0].0 = "layer.smartObjects.stackMode.none".into();
|
||||
let cleared = saved(&action);
|
||||
assert_eq!(cleared.event, AtnEvent::name(CLEAR));
|
||||
assert_eq!(cleared.descriptor, Some(Descriptor::new("null")));
|
||||
|
||||
let mut action = load(item(CLEAR, Some(Descriptor::new("null")))).actions.remove(0);
|
||||
action.steps[0].0 = "layer.smartObjects.stackMode.mean".into();
|
||||
let applied = saved(&action);
|
||||
assert_eq!(applied.event, AtnEvent::name(APPLY));
|
||||
assert_eq!(applied.descriptor, Some(descriptor("avrg", "Mean")));
|
||||
|
||||
let unknown_empty = photocraft_engine::paint::tile::b64_encode(&Descriptor::new("nope").to_bytes());
|
||||
let unknown_class = photocraft_engine::paint::tile::b64_encode(&descriptor("nope", "Mean").to_bytes());
|
||||
for command in ["layer.smartObjects.stackMode.mean", "layer.smartObjects.stackMode.none"] {
|
||||
for params in [json!(null), json!([]), json!({"layer":12}), json!({"layer":"Named"}), json!({"target":"mask"}), json!({"unknown":true})] {
|
||||
let action = Action::new("Mode", vec![(command.into(), params)]);
|
||||
assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err());
|
||||
}
|
||||
for origin in [json!(false), json!("!invalid-base64"), json!(""), json!(unknown_class), json!(unknown_empty)] {
|
||||
let mut action = Action::new("Mode", vec![(command.into(), json!({}))]);
|
||||
action.steps[0].1[ORIGIN] = origin;
|
||||
assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! #10723: recorded frame edits use the active video layer and the current playhead.
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Descriptor, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
const EVENTS: [(&str, &str); 3] = [
|
||||
("insertBlankFrame", "layer.videoLayers.insertBlankFrame"),
|
||||
("duplicateFrame", "layer.videoLayers.duplicateFrame"),
|
||||
("deleteFrame", "layer.videoLayers.deleteFrame"),
|
||||
];
|
||||
|
||||
/// Build typed source fixtures without asking the mapper to generate its own oracle.
|
||||
fn file(item: AtnItem) -> Vec<u8> {
|
||||
atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul("Video"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Frame edit"), items: vec![item], ..Default::default() }],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn item(event: &str, descriptor: Option<Descriptor>) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Exercise strict public export: skipping an unrepresentable step cannot pass this check.
|
||||
fn saved(action: &Action) -> AtnItem {
|
||||
let (bytes, skipped) = atn_cmds::export("Video", &[action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
atn::parse(&bytes).unwrap().actions[0].items[0].clone()
|
||||
}
|
||||
|
||||
/// Playback tests require a native mapping, rather than a successfully loaded kept step.
|
||||
fn load(s: &mut Session, source: AtnItem) {
|
||||
let bytes = file(source);
|
||||
let report = s.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(report["unsupported"], json!([]));
|
||||
}
|
||||
|
||||
/// Compare actual pixel markers; VideoData equality checks only stack shape.
|
||||
fn pixels(s: &Session, id: LayerId, original: bool) -> Vec<Vec<f32>> {
|
||||
let video = s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap();
|
||||
let frames = if original { video.original_frames.as_ref().unwrap() } else { &video.frames };
|
||||
frames.iter().map(|frame| frame.pixel(0, 0)).collect()
|
||||
}
|
||||
|
||||
/// Distinguish both frame order and original footage from the altered display cache.
|
||||
fn fixture(depth: u32) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":2,"height":2,"depth":depth,"background":"transparent"})).unwrap();
|
||||
s.execute("timeline.create", json!({"duration":3,"fps":24})).unwrap();
|
||||
let result = s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap();
|
||||
let id = LayerId(result["layer"].as_u64().unwrap());
|
||||
let state = s.active_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut state.doc);
|
||||
let mut frames = Vec::new();
|
||||
for red in [0.25, 0.5, 0.75] {
|
||||
let mut frame = photocraft_raster::Surface::new(doc.pixel_format());
|
||||
frame.write_pixel(0, 0, &[red, 0.0, 0.0, 1.0]);
|
||||
frames.push(frame);
|
||||
}
|
||||
let layer = doc.layer_mut(id).unwrap();
|
||||
layer.content = LayerContent::Raster(frames[1].clone());
|
||||
let video = layer.video.as_mut().unwrap();
|
||||
video.original_frames = Some(
|
||||
frames
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|frame| {
|
||||
let mut original = frame.clone();
|
||||
let red = original.pixel(0, 0)[0];
|
||||
original.write_pixel(0, 0, &[red, 1.0, 0.0, 1.0]);
|
||||
original
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
video.frames = frames;
|
||||
let timeline = doc.timeline.as_mut().unwrap();
|
||||
timeline.current = 1;
|
||||
timeline.work_start = 1;
|
||||
timeline.work_end = 2;
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Import's empty placeholder must not erase the wire's descriptor-presence distinction.
|
||||
fn descriptors_and_step_flags_round_trip_including_an_absent_descriptor() {
|
||||
for (event, command) in EVENTS {
|
||||
for descriptor in [None, Some(Descriptor::default())] {
|
||||
for enabled in [false, true] {
|
||||
let mut source = item(event, descriptor.clone());
|
||||
source.enabled = enabled;
|
||||
source.with_dialog = true;
|
||||
let imported = atn_cmds::import(&file(source)).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let action = &imported.actions[0];
|
||||
assert_eq!(action.steps[0].0, command);
|
||||
assert_eq!(action.disabled.contains(&0), !enabled);
|
||||
assert!(action.dialogs.contains(&0));
|
||||
let back = saved(action);
|
||||
assert_eq!(back.event, AtnEvent::name(event));
|
||||
assert_eq!(back.descriptor, descriptor);
|
||||
assert_eq!(back.enabled, enabled);
|
||||
assert!(back.with_dialog);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// One recorded command must change the correct stack position and remain one undoable edit.
|
||||
fn playback_edits_the_current_frame_and_original_stack_with_undo_redo_at_each_depth() {
|
||||
for (depth, has_descriptor) in [(8, true), (16, true), (32, true), (8, false)] {
|
||||
for (event, _) in EVENTS {
|
||||
let (mut s, id) = fixture(depth);
|
||||
let before = pixels(&s, id, false);
|
||||
let originals = pixels(&s, id, true);
|
||||
let timeline = s.active().unwrap().doc.timeline.clone();
|
||||
let expected = |frames: &[Vec<f32>]| match event {
|
||||
"insertBlankFrame" => vec![frames[0].clone(), frames[1].clone(), vec![0.0; 4], frames[2].clone()],
|
||||
"duplicateFrame" => vec![frames[0].clone(), frames[1].clone(), frames[1].clone(), frames[2].clone()],
|
||||
"deleteFrame" => vec![frames[0].clone(), frames[2].clone()],
|
||||
_ => panic!("fixture event"),
|
||||
};
|
||||
load(&mut s, item(event, has_descriptor.then(Descriptor::default)));
|
||||
let report = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(report.get("failed").is_none(), "{depth}/{event}: {report}");
|
||||
assert_eq!(report["ran"], 1);
|
||||
assert_eq!(pixels(&s, id, false), expected(&before), "{depth}/{event}");
|
||||
assert_eq!(pixels(&s, id, true), expected(&originals), "original {depth}/{event}");
|
||||
let t = s.active().unwrap().doc.timeline.as_ref().unwrap();
|
||||
assert_eq!((t.current, t.work_start, t.work_end), (1, 1, 2));
|
||||
assert_eq!(t.duration, if event == "deleteFrame" { 3 } else { 4 });
|
||||
let LayerContent::Raster(display) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("video display") };
|
||||
assert_eq!(display.pixel(0, 0), expected(&before)[1]);
|
||||
s.execute("history.undo", json!({})).unwrap();
|
||||
assert_eq!(pixels(&s, id, false), before);
|
||||
assert_eq!(pixels(&s, id, true), originals);
|
||||
assert_eq!(s.active().unwrap().doc.timeline, timeline);
|
||||
s.execute("history.redo", json!({})).unwrap();
|
||||
assert_eq!(pixels(&s, id, false), expected(&before));
|
||||
assert_eq!(pixels(&s, id, true), expected(&originals));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The native last-frame protection must also hold through recorded playback.
|
||||
fn deleting_the_last_frame_keeps_its_pixels() {
|
||||
let (mut s, id) = fixture(8);
|
||||
let state = s.active_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut state.doc);
|
||||
let layer = doc.layer_mut(id).unwrap();
|
||||
let video = layer.video.as_mut().unwrap();
|
||||
video.frames.truncate(1);
|
||||
video.original_frames.as_mut().unwrap().truncate(1);
|
||||
layer.content = LayerContent::Raster(video.frames[0].clone());
|
||||
let t = doc.timeline.as_mut().unwrap();
|
||||
*t = photocraft_doc::video::Timeline::new(1, 24.0);
|
||||
let before = pixels(&s, id, false);
|
||||
let original = pixels(&s, id, true);
|
||||
load(&mut s, item("deleteFrame", Some(Descriptor::default())));
|
||||
let report = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(report.get("failed").is_none(), "{report}");
|
||||
assert_eq!(pixels(&s, id, false), before);
|
||||
assert_eq!(pixels(&s, id, true), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Recognizing an event name must never discard settings or reinterpret its unknown header.
|
||||
fn unrepresented_settings_and_descriptor_metadata_stay_complete_kept_steps() {
|
||||
let named = Descriptor { name: UnicodeString::new_nul("Unmeasured metadata"), ..Default::default() };
|
||||
for (event, _) in EVENTS {
|
||||
for descriptor in [Descriptor::default().with("frame", D::Integer(2)), Descriptor::new("other"), named.clone()] {
|
||||
let imported = atn_cmds::import(&file(item(event, Some(descriptor.clone())))).unwrap();
|
||||
assert_eq!(imported.unsupported.len(), 1);
|
||||
assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep");
|
||||
assert_eq!(saved(&imported.actions[0]).descriptor, Some(descriptor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Current-frame recordings cannot serialize an explicit frame, layer or mask selector.
|
||||
fn native_options_and_damaged_absence_metadata_refuse_export() {
|
||||
for (_, command) in EVENTS {
|
||||
for params in [
|
||||
json!({"frame":2}),
|
||||
json!({"layer":3}),
|
||||
json!({"target":"mask"}),
|
||||
json!({"_atnVideoFrameNoDescriptor":false}),
|
||||
json!({"_atnVideoFrameNoDescriptor":"true"}),
|
||||
] {
|
||||
let action = Action::new("Invalid", vec![(command.into(), params)]);
|
||||
assert!(atn_cmds::export("Video", &[&action], &[], false).is_err(), "{command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Mapping a known event cannot bypass the native video-target precondition.
|
||||
fn playback_without_a_video_target_fails_without_editing_a_document() {
|
||||
for (event, _) in EVENTS {
|
||||
for document in [false, true] {
|
||||
let mut s = Session::new();
|
||||
if document {
|
||||
s.execute("file.new", json!({"width":2,"height":2})).unwrap();
|
||||
}
|
||||
let before = s.active().map(|state| state.doc.clone());
|
||||
load(&mut s, item(event, Some(Descriptor::default())));
|
||||
let report = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(report.get("failed").is_some(), "{event}: {report}");
|
||||
match before {
|
||||
Some(before) => assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)),
|
||||
None => assert!(s.active().is_none()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! #10868: crop fill samples the Background and editable active raster independently.
|
||||
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, TextLayer};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
const YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
|
||||
|
||||
struct Layers {
|
||||
background: LayerId,
|
||||
quads: LayerId,
|
||||
small: LayerId,
|
||||
edge: LayerId,
|
||||
hidden: LayerId,
|
||||
text: LayerId,
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
fn raster(d: &Document, name: &str, bounds: Rect, color: [f32; 4]) -> Layer {
|
||||
let fmt = d.pixel_format();
|
||||
let mut layer = Layer::raster(name, fmt);
|
||||
layer.surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color));
|
||||
layer
|
||||
}
|
||||
|
||||
/// Keeps the measured straight geometry and fits turned controls under the 1 MiB transform limit
|
||||
/// that the preference test can set process-wide.
|
||||
fn scene_divisor(turned: bool) -> i32 {
|
||||
if turned { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// The measured scene, with a deliberately different crop extension colour.
|
||||
fn scene(turned: bool) -> (Session, Layers) {
|
||||
let divisor = scene_divisor(turned);
|
||||
let rect = |x0, y0, x1, y1| Rect::new(x0 / divisor, y0 / divisor, x1 / divisor, y1 / divisor);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":800/divisor,"height":500/divisor,"depth":8,"background":"white"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap();
|
||||
let ids = s
|
||||
.edit("Fixture", |d, _| {
|
||||
let background = d.background().unwrap().id;
|
||||
let mut quads = raster(d, "Quads", rect(0, 0, 800, 500), RED);
|
||||
let fmt = d.pixel_format();
|
||||
let surf = quads.surface_mut().unwrap();
|
||||
for (bounds, color) in [(rect(400, 0, 800, 250), GREEN), (rect(0, 250, 400, 500), BLUE), (rect(400, 250, 800, 500), YELLOW)] {
|
||||
surf.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color));
|
||||
}
|
||||
let small = raster(d, "Small", rect(350, 200, 450, 300), RED);
|
||||
let edge = raster(d, "EdgeHalf", rect(600, 0, 800, 250), BLUE);
|
||||
let mut hidden = raster(d, "Hidden", rect(0, 0, 800, 500), RED);
|
||||
hidden.visible = false;
|
||||
let text = Layer::new("T", LayerContent::Text(TextLayer { text: "T".into(), ..Default::default() }));
|
||||
let ids = Layers { background, quads: quads.id, small: small.id, edge: edge.id, hidden: hidden.id, text: text.id };
|
||||
d.layers.extend([quads, small, edge, hidden, text]);
|
||||
Ok(ids)
|
||||
})
|
||||
.unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, id: LayerId) {
|
||||
s.execute("layer.select", json!({"layer":id.0})).unwrap();
|
||||
}
|
||||
|
||||
/// Extends the frame to the right by one eighth of the original width.
|
||||
fn frame(turned: bool) -> Value {
|
||||
let divisor = scene_divisor(turned);
|
||||
json!({"x":0,"y":0,"width":900/divisor,"height":500/divisor,"angle":if turned { 10 } else { 0 },"deleteCroppedPixels":true})
|
||||
}
|
||||
|
||||
/// Samples the same relative point inside the new strip in both fixture sizes.
|
||||
fn extension_sample(turned: bool) -> (i32, i32) {
|
||||
let divisor = scene_divisor(turned);
|
||||
(850 / divisor, 400 / divisor)
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, mut p: Value, aware: bool) {
|
||||
p["contentAware"] = json!(aware);
|
||||
s.execute("image.crop", p).unwrap();
|
||||
}
|
||||
|
||||
fn plain_control(s: &mut Session, p: &Value) -> Document {
|
||||
crop(s, p.clone(), false);
|
||||
let plain = doc(s).clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
plain
|
||||
}
|
||||
|
||||
/// Checks donor colour with room for the resampling and synthesis tolerance.
|
||||
fn pixel(d: &Document, id: LayerId, x: i32, y: i32, want: [f32; 4]) {
|
||||
let actual = d.layer(id).unwrap().surface().unwrap().rgba(x, y);
|
||||
assert!(actual.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.06), "layer {id:?} at ({x},{y}): {actual:?}, expected {want:?}");
|
||||
}
|
||||
|
||||
/// Compares complete layers with plain crop, including any legitimate turn or clipping.
|
||||
fn unchanged(d: &Document, plain: &Document, ids: &[LayerId]) {
|
||||
for id in ids {
|
||||
// Includes bounds, pixels and metadata; turned crops legitimately transform these layers.
|
||||
assert_eq!(d.layer(*id), plain.layer(*id), "non-target {id:?} differs from plain crop");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pixel of `id` inside `area` (the old canvas) is the plain crop's.
|
||||
fn covered_as_plain(d: &Document, plain: &Document, id: LayerId, area: Rect) {
|
||||
let (got, want) = (d.layer(id).unwrap().surface().unwrap(), plain.layer(id).unwrap().surface().unwrap());
|
||||
for y in (area.y0..area.y1).step_by(7) {
|
||||
for x in (area.x0..area.x1).step_by(7) {
|
||||
assert_eq!(got.rgba(x, y), want.rgba(x, y), "layer {id:?} at ({x},{y}) differs from the plain crop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named_one_step(s: &Session, past: usize, name: &str) {
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), past + 1);
|
||||
assert_eq!(st.history.undo_label(), Some(name));
|
||||
}
|
||||
|
||||
fn undo_redo(s: &mut Session, before: &Document) {
|
||||
let after = doc(s).clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(s), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(doc(s), &after);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Content-aware Crop"));
|
||||
}
|
||||
|
||||
/// Matches the three measured rows at their original size, bounds and sample points.
|
||||
#[test]
|
||||
fn measured_straight_rows_fill_background_and_only_the_active_raster() {
|
||||
for row in 0..3 {
|
||||
let (mut s, ids) = scene(false);
|
||||
let active = if row == 1 { ids.edge } else { ids.quads };
|
||||
if row == 2 {
|
||||
s.edit("Layer 0", |d, _| {
|
||||
let bg = d.layer_mut(ids.background).unwrap();
|
||||
bg.name = "Layer 0".into();
|
||||
bg.locks = Default::default();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
select(&mut s, active);
|
||||
let before = doc(&s).clone();
|
||||
let p = frame(false);
|
||||
let plain = plain_control(&mut s, &p);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
crop(&mut s, p, true);
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.size.width, d.size.height), (900, 500));
|
||||
let mut untouched = vec![ids.small, ids.hidden, ids.text];
|
||||
untouched.push(if row == 1 { ids.quads } else { ids.edge });
|
||||
if row == 2 {
|
||||
assert!(d.background().is_none());
|
||||
untouched.push(ids.background);
|
||||
} else {
|
||||
assert_eq!(d.layer(ids.background).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 900, 500));
|
||||
pixel(d, ids.background, 850, 400, WHITE);
|
||||
}
|
||||
let bounds = if row == 1 { Rect::new(600, 0, 900, 500) } else { Rect::new(0, 0, 900, 500) };
|
||||
assert_eq!(d.layer(active).unwrap().surface().unwrap().content_bounds(), bounds);
|
||||
pixel(d, active, 850, if row == 1 { 400 } else { 125 }, if row == 1 { BLUE } else { GREEN });
|
||||
// The fill adds pixels only where the crop uncovered the canvas: the active layer's own
|
||||
// pixels inside the old canvas are the plain crop's.
|
||||
covered_as_plain(d, &plain, active, Rect::new(0, 0, 800, 500));
|
||||
unchanged(d, &plain, &untouched);
|
||||
named_one_step(&s, past, "Content-aware Crop");
|
||||
undo_redo(&mut s, &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks own-layer donors and exact non-target parity after rotation.
|
||||
#[test]
|
||||
fn turned_rows_sample_each_target_and_leave_other_layers_at_plain_crop() {
|
||||
let (x, y) = extension_sample(true);
|
||||
for row in 0..3 {
|
||||
let (mut s, ids) = scene(true);
|
||||
// Uniform donors isolate target selection from the existing turned-fill sampling policy.
|
||||
s.edit("Uniform Quads", |d, _| {
|
||||
let fmt = d.pixel_format();
|
||||
let bounds = d.bounds();
|
||||
d.layer_mut(ids.quads).unwrap().surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, GREEN));
|
||||
if row == 2 {
|
||||
d.layer_mut(ids.background).unwrap().locks = Default::default();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let active = if row == 1 { ids.edge } else { ids.quads };
|
||||
select(&mut s, active);
|
||||
let before = doc(&s).clone();
|
||||
let p = frame(true);
|
||||
let plain = plain_control(&mut s, &p);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let mut p = p;
|
||||
p["contentAware"] = json!(true);
|
||||
s.execute("image.crop", p).unwrap_or_else(|error| panic!("turned row {row}, active {active:?}: {error}"));
|
||||
let d = doc(&s);
|
||||
pixel(d, active, x, y, if row == 1 { BLUE } else { GREEN });
|
||||
assert!(d.layer(active).unwrap().surface().unwrap().content_bounds().contains(x, y));
|
||||
let mut untouched = vec![ids.small, ids.hidden, ids.text, if row == 1 { ids.quads } else { ids.edge }];
|
||||
if row == 2 {
|
||||
untouched.push(ids.background);
|
||||
} else {
|
||||
pixel(d, ids.background, x, y, WHITE);
|
||||
}
|
||||
unchanged(d, &plain, &untouched);
|
||||
named_one_step(&s, past, "Content-aware Crop");
|
||||
undo_redo(&mut s, &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selecting the Background leaves each other layer identical to its plain crop.
|
||||
#[test]
|
||||
fn background_active_is_not_a_second_target() {
|
||||
for turned in [false, true] {
|
||||
let (x, y) = extension_sample(turned);
|
||||
let (mut s, ids) = scene(turned);
|
||||
select(&mut s, ids.background);
|
||||
let p = frame(turned);
|
||||
let plain = plain_control(&mut s, &p);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
crop(&mut s, p, true);
|
||||
pixel(doc(&s), ids.background, x, y, WHITE);
|
||||
unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
|
||||
named_one_step(&s, past, "Content-aware Crop");
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies Background-only fill for ineligible active layers and Delete Cropped Pixels off.
|
||||
#[test]
|
||||
fn unmeasured_active_targets_and_delete_off_keep_the_legacy_target() {
|
||||
for turned in [false, true] {
|
||||
let (x, y) = extension_sample(turned);
|
||||
for case in 0..10 {
|
||||
let (mut s, ids) = scene(turned);
|
||||
let active = match case {
|
||||
0 => ids.text,
|
||||
1 => ids.hidden,
|
||||
_ => ids.quads,
|
||||
};
|
||||
if (2..7).contains(&case) {
|
||||
s.edit("Lock active", |d, _| {
|
||||
assert!(d.layer_mut(active).unwrap().locks.set(["all", "pixels", "transparency", "position", "artboard"][case - 2], true));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
if case >= 8 {
|
||||
s.edit("Group active", |d, _| {
|
||||
let i = d.layers.iter().position(|layer| layer.id == active).unwrap();
|
||||
let child = d.layers.remove(i);
|
||||
let mut group = Layer::group("Ancestor", vec![child]);
|
||||
if case == 8 {
|
||||
group.visible = false;
|
||||
} else {
|
||||
assert!(group.locks.set("pixels", true));
|
||||
}
|
||||
d.layers.insert(i, group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
select(&mut s, active);
|
||||
let mut p = frame(turned);
|
||||
if case == 7 {
|
||||
p["deleteCroppedPixels"] = json!(false);
|
||||
}
|
||||
let plain = plain_control(&mut s, &p);
|
||||
crop(&mut s, p, true);
|
||||
pixel(doc(&s), ids.background, x, y, WHITE);
|
||||
unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the plain history name for interior crops and crops with synthesis disabled.
|
||||
#[test]
|
||||
fn no_fill_or_content_aware_off_keeps_the_crop_history_name() {
|
||||
for (p, aware, turned) in [
|
||||
(json!({"x":20,"y":20,"width":40,"height":30}), true, false),
|
||||
(json!({"x":20,"y":20,"width":40,"height":30,"angle":10}), true, true),
|
||||
(frame(false), false, false),
|
||||
(frame(true), false, true),
|
||||
] {
|
||||
let (mut s, ids) = scene(turned);
|
||||
select(&mut s, ids.quads);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
crop(&mut s, p, aware);
|
||||
named_one_step(&s, past, "Crop");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives exactly one target a strip of held pixels outside the original canvas.
|
||||
fn overhang_scene(held_active: bool) -> (Session, LayerId, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":64,"height":48,"background":"white"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap();
|
||||
let (bg, active) = s
|
||||
.edit("Distinct hidden pixels", |d, active| {
|
||||
let bg = d.background().unwrap().id;
|
||||
let mut layer = raster(d, "Active", Rect::new(0, 0, 64, 48), GREEN);
|
||||
let fmt = d.pixel_format();
|
||||
let surf = if held_active { layer.surface_mut().unwrap() } else { d.layer_mut(bg).unwrap().surface_mut().unwrap() };
|
||||
surf.fill_rect(Rect::new(-16, 0, 0, 48), &photocraft_raster::from_rgba(&fmt, if held_active { BLUE } else { RED }));
|
||||
let id = layer.id;
|
||||
d.layers.push(layer);
|
||||
*active = Some(id);
|
||||
Ok((bg, id))
|
||||
})
|
||||
.unwrap();
|
||||
(s, bg, active)
|
||||
}
|
||||
|
||||
/// Confirms that held pixels protect only the layer that owned them before the turn.
|
||||
#[test]
|
||||
fn turned_hidden_overhang_coverage_belongs_to_each_layer() {
|
||||
for held_active in [false, true] {
|
||||
let (mut s, bg, active) = overhang_scene(held_active);
|
||||
crop(&mut s, json!({"x":-10,"y":14,"width":20,"height":20,"angle":45}), true);
|
||||
let mut held_pixels = 0;
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let (held, other, want) = if held_active { (active, bg, WHITE) } else { (bg, active, GREEN) };
|
||||
pixel(doc(&s), other, x, y, want);
|
||||
let px = doc(&s).layer(held).unwrap().surface().unwrap().rgba(x, y);
|
||||
let color = if held_active { BLUE } else { RED };
|
||||
held_pixels += usize::from(px.iter().zip(color).all(|(a, b)| (a - b).abs() < 0.06));
|
||||
}
|
||||
}
|
||||
assert!(held_pixels >= 100, "the target's own hidden pixels survive: {held_pixels}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty active raster leaves the successful Background fill and undo step intact.
|
||||
#[test]
|
||||
fn donorless_added_target_keeps_the_background_fill() {
|
||||
for turned in [false, true] {
|
||||
let (x, y) = extension_sample(turned);
|
||||
let (mut s, ids) = scene(turned);
|
||||
let empty = s
|
||||
.edit("Empty target", |d, active| {
|
||||
let layer = Layer::raster("Empty", d.pixel_format());
|
||||
let id = layer.id;
|
||||
d.layers.push(layer);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
assert_ne!(empty, ids.background);
|
||||
let before = doc(&s).clone();
|
||||
let p = frame(turned);
|
||||
let plain = plain_control(&mut s, &p);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
crop(&mut s, p, true);
|
||||
pixel(doc(&s), ids.background, x, y, WHITE);
|
||||
unchanged(doc(&s), &plain, &[empty, ids.quads, ids.small, ids.edge, ids.hidden, ids.text]);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(empty));
|
||||
named_one_step(&s, past, "Content-aware Crop");
|
||||
undo_redo(&mut s, &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirms both layers synthesize their own colours at every RGB depth.
|
||||
#[test]
|
||||
fn both_target_colours_remain_native_at_each_rgb_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":32,"height":24,"depth":depth,"background":"white"})).unwrap();
|
||||
let (bg, active) = s
|
||||
.edit("Own colours", |d, active| {
|
||||
let bg = d.background().unwrap().id;
|
||||
let layer = raster(d, "Green", Rect::new(0, 0, 32, 24), GREEN);
|
||||
let id = layer.id;
|
||||
d.layers.push(layer);
|
||||
*active = Some(id);
|
||||
Ok((bg, id))
|
||||
})
|
||||
.unwrap();
|
||||
crop(&mut s, json!({"x":0,"y":0,"width":40,"height":24}), true);
|
||||
pixel(doc(&s), bg, 36, 12, WHITE);
|
||||
pixel(doc(&s), active, 36, 12, GREEN);
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,18 @@ mod atn_dialog_filters;
|
||||
mod atn_direct_adjustments;
|
||||
mod atn_layer_style;
|
||||
mod atn_newer_events;
|
||||
mod atn_perspective_warp;
|
||||
mod atn_reference_forms;
|
||||
mod atn_review_regressions;
|
||||
mod atn_ripple_amount;
|
||||
mod atn_selective_color;
|
||||
mod atn_shape_blur;
|
||||
mod atn_shear;
|
||||
mod atn_stack_modes;
|
||||
mod atn_stroke_width;
|
||||
mod atn_type_path;
|
||||
mod atn_v12;
|
||||
mod atn_video_frames;
|
||||
mod background_and_mask_depth;
|
||||
mod background_layer_consistency;
|
||||
mod brush_smoothing;
|
||||
@@ -35,6 +38,7 @@ mod convert_keeps_references;
|
||||
mod copy_shape_placement;
|
||||
mod corpus_atn;
|
||||
mod crop_content_aware;
|
||||
mod crop_content_aware_layers;
|
||||
mod crop_delete_off_background;
|
||||
mod current_affinity_artboards;
|
||||
mod duplicate_layer_comps;
|
||||
|
||||
@@ -63,6 +63,35 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() {
|
||||
assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98);
|
||||
}
|
||||
|
||||
/// A `.pcraft` opens from its cached pixels and parses an embedded SVG source only on a later
|
||||
/// edit. A source whose DTD entity hides 20 000 nested groups from the depth check used to open
|
||||
/// clean and then overflow the stack on Image Size or a PSD save; both are an error or a fallback
|
||||
/// now. Run on a 2 MB stack, as the app's import and command threads are.
|
||||
#[test]
|
||||
fn a_pcraft_smart_object_with_a_hostile_svg_does_not_abort_a_later_edit() {
|
||||
let deep = format!("{}{}", "<g>".repeat(20_000), "</g>".repeat(20_000));
|
||||
let hostile = format!("<!DOCTYPE svg [<!ENTITY a \"{deep}\">]><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\">&a;</svg>");
|
||||
std::thread::Builder::new()
|
||||
.stack_size(2 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100, "background": "white"})).unwrap();
|
||||
photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap();
|
||||
let mut doc = (*s.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layers.last_mut().unwrap().content else { panic!("smart object") };
|
||||
sm.source = photocraft_doc::SmartSource::Embedded { file_name: "logo.svg".into(), bytes: std::sync::Arc::new(hostile.into_bytes()) };
|
||||
let carrier = photocraft_format::save_to_bytes(&doc, &photocraft_format::SaveOptions::default()).unwrap();
|
||||
|
||||
photocraft_engine::file_cmds::open_bytes_as(&mut s, "carrier.pcraft", &carrier, None, None).unwrap();
|
||||
let _ = s.execute("image.imageSize", json!({"width": 50, "height": 50}));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _ = s.execute("file.saveACopy", json!({"path": dir.path().join("copy.psd").to_string_lossy()}));
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Placed through a background job, an SVG is rendered at its placement scale on the worker and
|
||||
/// lands exactly as the inline place does: still sharp, not a resampled raster.
|
||||
#[test]
|
||||
|
||||
@@ -49,6 +49,8 @@ imagesize = "0.13"
|
||||
# already in the tree through the UI's icon loader; text shaping needs the fonts feature set.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] }
|
||||
# The SVG fallback font search keeps each font file's character map (already in the tree through usvg).
|
||||
ttf-parser = { version = "0.25", default-features = false }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
|
||||
|
||||
@@ -38,7 +38,7 @@ const FILTER_ID: i32 = 1023;
|
||||
const NAME: &str = "Perspective Warp...";
|
||||
pub(super) const TEMPLATE_KEY: &str = "__perspectiveWarpPsd";
|
||||
/// Far more planes than anyone lays out by hand; bounds the corner linking (quadratic).
|
||||
const MAX_QUADS: usize = 256;
|
||||
pub const MAX_QUADS: usize = 256;
|
||||
const MAX_VERTICES: usize = 4 * MAX_QUADS;
|
||||
const MAX_TEMPLATE_BYTES: usize = 1 << 20;
|
||||
|
||||
@@ -74,29 +74,29 @@ fn quads(f: &Descriptor) -> Option<Vec<[usize; 4]>> {
|
||||
.map(|q| {
|
||||
let Value::Descriptor(q) = q else { return None };
|
||||
let Some(Value::List(ix)) = q.get("indices") else { return None };
|
||||
let ix: Vec<usize> = ix
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
Value::Integer(i) => usize::try_from(*i).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
ix.try_into().ok()
|
||||
let [Value::Integer(a), Value::Integer(b), Value::Integer(c), Value::Integer(d)] = ix.as_slice() else { return None };
|
||||
Some([usize::try_from(*a).ok()?, usize::try_from(*b).ok()?, usize::try_from(*c).ok()?, usize::try_from(*d).ok()?])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The planes a `perspectiveWarpTransform` descriptor describes; `None` when it is inconsistent.
|
||||
fn planes_of(f: &Descriptor) -> Option<Vec<Plane>> {
|
||||
/// Reads the document-space planes and original corner indices from Perspective Warp geometry.
|
||||
/// Callers validate the enclosing descriptor and target according to the file format.
|
||||
pub fn indexed_geometry(f: &Descriptor) -> Option<(Vec<Plane>, Vec<[usize; 4]>)> {
|
||||
let (src, dst) = (points(f, "vertices")?, points(f, "warpedVertices")?);
|
||||
if src.len() != dst.len() {
|
||||
return None;
|
||||
}
|
||||
let corners =
|
||||
|list: &[[f64; 2]], q: &[usize; 4]| -> Option<[[f64; 2]; 4]> { Some([*list.get(q[0])?, *list.get(q[1])?, *list.get(q[2])?, *list.get(q[3])?]) };
|
||||
let planes: Vec<Plane> = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::<Option<_>>()?;
|
||||
let indices = quads(f)?;
|
||||
let planes: Vec<Plane> = indices.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::<Option<_>>()?;
|
||||
PerspectiveMap::new(&planes)?;
|
||||
Some(planes)
|
||||
Some((planes, indices))
|
||||
}
|
||||
|
||||
fn planes_of(f: &Descriptor) -> Option<Vec<Plane>> {
|
||||
indexed_geometry(f).map(|(planes, _)| planes)
|
||||
}
|
||||
|
||||
/// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item.
|
||||
@@ -137,7 +137,8 @@ fn template(params: &J) -> Result<Option<Descriptor>, String> {
|
||||
/// The planes to write: as rendered, so a `straighten` is baked in.
|
||||
fn export_planes(params: &J) -> Result<Vec<Plane>, String> {
|
||||
let object = params.as_object().ok_or("Perspective Warp parameters must be an object")?;
|
||||
if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["planes", "straighten", "interpolation"].contains(&k.as_str())) {
|
||||
let supported = |k: &str| super::is_meta(k) || ["planes", "straighten", "interpolation"].contains(&k) || (k == "target" && params["target"] == "pixels");
|
||||
if let Some(k) = object.keys().find(|k| !supported(k)) {
|
||||
return Err(format!("Perspective Warp setting `{k}` has no PSD equivalent"));
|
||||
}
|
||||
let mut planes: Vec<Plane> =
|
||||
@@ -161,7 +162,7 @@ fn pnt(p: [f64; 2]) -> Value {
|
||||
|
||||
/// `vertices`, `warpedVertices` and `quads` for `planes`; a corner shared by several planes (same
|
||||
/// layout and warped position) is written once.
|
||||
fn geometry(planes: &[Plane]) -> (Value, Value, Value) {
|
||||
pub fn geometry(planes: &[Plane]) -> (Value, Value, Value) {
|
||||
let mut corners: Vec<([f64; 2], [f64; 2])> = Vec::new();
|
||||
let mut quads = Vec::with_capacity(planes.len());
|
||||
for p in planes {
|
||||
|
||||
@@ -51,6 +51,9 @@ mod hue_sat;
|
||||
mod perspective_warp;
|
||||
|
||||
pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map};
|
||||
pub use perspective_warp::{
|
||||
MAX_QUADS as MAX_PERSPECTIVE_WARP_PLANES, geometry as perspective_warp_geometry, indexed_geometry as perspective_warp_indexed_geometry,
|
||||
};
|
||||
|
||||
mod export;
|
||||
mod import;
|
||||
|
||||
+114
-9
@@ -281,15 +281,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc<usvg::fontdb::Database>) -> usvg
|
||||
loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id)
|
||||
}
|
||||
|
||||
/// Most `cmap` table bytes [`has_char`] keeps; past it a face is asked on disk (slow, still right).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CACHED_CMAP_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Whether face `face` maps `c` to a glyph, as usvg's own check decides it. usvg reads a font
|
||||
/// file's whole font for every face it asks, so one character no font has re-read every
|
||||
/// installed font once per text element (430 ms each with 273 faces). A file face's `cmap` table
|
||||
/// is kept instead, read once per process; a file that doesn't load isn't remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn has_char(db: &usvg::fontdb::Database, face: &usvg::fontdb::FaceInfo, c: char) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use ttf_parser::{Face, Tag, cmap};
|
||||
type Cmaps = (HashMap<(PathBuf, u32), Arc<Vec<u8>>>, usize);
|
||||
static CMAPS: OnceLock<Mutex<Cmaps>> = OnceLock::new();
|
||||
let maps = || CMAPS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let in_cmap =
|
||||
|table: &[u8]| cmap::Table::parse(table).is_some_and(|t| t.subtables.into_iter().any(|s| s.is_unicode() && s.glyph_index(u32::from(c)).is_some()));
|
||||
let usvg_check = |data: &[u8], index| Face::parse(data, index).is_ok_and(|f| f.glyph_index(c).is_some());
|
||||
let usvg::fontdb::Source::File(path) = &face.source else {
|
||||
return db.with_face_data(face.id, usvg_check).unwrap_or(false);
|
||||
};
|
||||
let key = (path.clone(), face.index);
|
||||
if let Some(table) = maps().0.get(&key).cloned() {
|
||||
return in_cmap(&table);
|
||||
}
|
||||
// usvg's check parses the whole face first: one that doesn't parse has no characters. Its
|
||||
// `cmap` is the last such table record (a linear walk, unsorted directories included).
|
||||
let cmap_of = |data: &[u8], index| {
|
||||
let face = Face::parse(data, index).ok()?;
|
||||
let raw = face.raw_face();
|
||||
let record = raw.table_records.into_iter().filter(|r| r.tag == Tag::from_bytes(b"cmap")).last();
|
||||
let table = record.and_then(|r| {
|
||||
let start = usize::try_from(r.offset).ok()?;
|
||||
raw.data.get(start..start.checked_add(usize::try_from(r.length).ok()?)?)
|
||||
});
|
||||
Some(table.unwrap_or_default().to_vec())
|
||||
};
|
||||
let Some(table) = db.with_face_data(face.id, cmap_of).flatten() else {
|
||||
return false;
|
||||
};
|
||||
let found = in_cmap(&table);
|
||||
let mut maps = maps();
|
||||
// An import that missed at the same time may have kept it already.
|
||||
if !maps.0.contains_key(&key) && maps.1.saturating_add(table.len()) <= MAX_CACHED_CMAP_BYTES {
|
||||
maps.1 = maps.1.saturating_add(table.len());
|
||||
maps.0.insert(key, Arc::new(table));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// usvg's fallback choice (the first face, in database order, not used yet, sharing the base
|
||||
/// face's style, weight or stretch and having `c`), asked through [`has_char`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fallback_face(c: char, used: &[usvg::fontdb::ID], db: &usvg::fontdb::Database) -> Option<usvg::fontdb::ID> {
|
||||
let base = db.face(*used.first()?)?;
|
||||
db.faces()
|
||||
.filter(|f| !used.contains(&f.id))
|
||||
.filter(|f| base.style == f.style || base.weight == f.weight || base.stretch == f.stretch)
|
||||
.find(|f| has_char(db, f, c))
|
||||
.map(|f| f.id)
|
||||
}
|
||||
|
||||
/// usvg's font choice, with the chosen faces read from memory ([`in_memory`]).
|
||||
fn font_resolver() -> usvg::FontResolver<'static> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let select = usvg::FontResolver::default_font_selector();
|
||||
let fallback = usvg::FontResolver::default_fallback_selector();
|
||||
usvg::FontResolver {
|
||||
select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))),
|
||||
select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))),
|
||||
select_fallback: Box::new(|c, used, db| fallback_face(c, used, db).map(|id| in_memory(id, db))),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -329,7 +392,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>, depth_left: usize) -> usvg::Opti
|
||||
Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)),
|
||||
_ => return None,
|
||||
};
|
||||
if nesting_depth(&xml, depth_left) > depth_left {
|
||||
if !nesting_depth(&xml, depth_left).is_ok_and(|depth| depth <= depth_left) {
|
||||
return None;
|
||||
}
|
||||
return default_resolver(mime, xml, opts);
|
||||
@@ -366,7 +429,7 @@ fn parse(bytes: &[u8]) -> Result<Parsed, IoError> {
|
||||
/// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one).
|
||||
fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<Parsed, IoError> {
|
||||
let xml = inflate(bytes)?;
|
||||
let depth = nesting_depth(&xml, MAX_XML_DEPTH);
|
||||
let depth = nesting_depth(&xml, MAX_XML_DEPTH)?;
|
||||
if depth > MAX_XML_DEPTH {
|
||||
return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep")));
|
||||
}
|
||||
@@ -627,7 +690,7 @@ fn parse_image_bounded_with(
|
||||
if left.is_none() {
|
||||
return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into())));
|
||||
}
|
||||
let nesting = nesting_depth(&xml, xml_left);
|
||||
let nesting = nesting_depth(&xml, xml_left)?;
|
||||
if nesting > xml_left {
|
||||
return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep")));
|
||||
}
|
||||
@@ -806,8 +869,10 @@ fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result<std::borrow::Cow<'_, [
|
||||
/// The deepest element nesting in `xml`, stopping as soon as it passes `limit` (the result is
|
||||
/// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add
|
||||
/// nothing; quoted attribute values may hold `>` or `/>`. Malformed markup is only counted
|
||||
/// loosely: the parser rejects it afterwards.
|
||||
pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
/// loosely: the parser rejects it afterwards. The parser expands the DTD's entities after this
|
||||
/// count, so an entity holding markup (a `<`) is an error: it could nest deeper than anything
|
||||
/// counted here. A `<` there stays text.
|
||||
pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> Result<usize, IoError> {
|
||||
let after = |from: usize, pat: &[u8]| -> usize {
|
||||
xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len()))
|
||||
};
|
||||
@@ -829,6 +894,44 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
}
|
||||
(xml.len(), false)
|
||||
};
|
||||
// The byte after the DOCTYPE starting at `from`: its internal subset `[...]` is walked
|
||||
// declaration by declaration, so a quote in a comment there can't hide the markup after it.
|
||||
let doctype_end = |from: usize| -> Result<usize, IoError> {
|
||||
let (mut quote, mut j) = (None, from);
|
||||
loop {
|
||||
let Some(&b) = xml.get(j) else { return Ok(xml.len()) };
|
||||
j = j.saturating_add(1);
|
||||
match quote {
|
||||
Some(q) if b == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if b == b'"' || b == b'\'' => quote = Some(b),
|
||||
None if b == b'>' => return Ok(j),
|
||||
None if b == b'[' => break,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
while let Some(rest) = xml.get(j..).filter(|r| !r.is_empty()) {
|
||||
j = if rest.starts_with(b"<!--") {
|
||||
after(j.saturating_add(4), b"-->")
|
||||
} else if rest.starts_with(b"<?") {
|
||||
after(j.saturating_add(2), b"?>")
|
||||
} else if rest.starts_with(b"<!ENTITY") {
|
||||
let end = tag_end(j.saturating_add(2)).0;
|
||||
if xml.get(j.saturating_add(2)..end).is_some_and(|decl| decl.contains(&b'<')) {
|
||||
return Err(IoError::Svg("the SVG's DTD declares an entity holding markup, which isn't supported".into()));
|
||||
}
|
||||
end
|
||||
} else if rest.starts_with(b"<!") {
|
||||
// The parser ends ELEMENT, ATTLIST and NOTATION at the first `>`, quotes or not.
|
||||
after(j.saturating_add(2), b">")
|
||||
} else if rest.starts_with(b"]") {
|
||||
return Ok(tag_end(j.saturating_add(1)).0);
|
||||
} else {
|
||||
j.saturating_add(1)
|
||||
};
|
||||
}
|
||||
Ok(xml.len())
|
||||
};
|
||||
let (mut depth, mut deepest, mut i) = (0usize, 0usize, 0usize);
|
||||
while let Some(p) = xml.get(i..).and_then(|s| s.iter().position(|&b| b == b'<')) {
|
||||
let at = i.saturating_add(p);
|
||||
@@ -839,6 +942,8 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
after(at.saturating_add(9), b"]]>")
|
||||
} else if rest.starts_with(b"<?") {
|
||||
after(at.saturating_add(2), b"?>")
|
||||
} else if rest.starts_with(b"<!DOCTYPE") {
|
||||
doctype_end(at.saturating_add(9))?
|
||||
} else if rest.starts_with(b"<!") {
|
||||
tag_end(at.saturating_add(2)).0
|
||||
} else if rest.starts_with(b"</") {
|
||||
@@ -850,13 +955,13 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
depth = depth.saturating_add(1);
|
||||
deepest = deepest.max(depth);
|
||||
if deepest > limit {
|
||||
return deepest;
|
||||
return Ok(deepest);
|
||||
}
|
||||
}
|
||||
end
|
||||
};
|
||||
}
|
||||
deepest
|
||||
Ok(deepest)
|
||||
}
|
||||
|
||||
/// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed.
|
||||
|
||||
@@ -487,6 +487,79 @@ fn imports_missing_the_same_font_at_once_share_one_read() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9671: the fallback search for a character the text's font lacks reads each font file's
|
||||
/// character map once per process, not the whole file on every text element (430 ms an
|
||||
/// element with 273 installed faces). A second import still finds the fallback face after
|
||||
/// its file stopped being readable, so it didn't go back to the disk.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_fallback_search_reads_each_font_file_once() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static GONE: AtomicBool = AtomicBool::new(false);
|
||||
fn path() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("photocraft-svg-test-fallback-{}.ttf", std::process::id()))
|
||||
}
|
||||
fn inter_and_mono_on_disk() -> Arc<usvg::fontdb::Database> {
|
||||
std::fs::write(path(), include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap();
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec());
|
||||
db.load_font_file(path()).unwrap();
|
||||
if GONE.load(Ordering::SeqCst) {
|
||||
std::fs::write(path(), b"not a font any more").unwrap();
|
||||
}
|
||||
Arc::new(db)
|
||||
}
|
||||
// Inter has no box-drawing characters; JetBrains Mono has.
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\" font-family=\"Inter\">I\u{2500}I</text></svg>";
|
||||
let outlines = |parsed: Parsed| {
|
||||
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers;
|
||||
let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None });
|
||||
groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::<Vec<_>>()
|
||||
};
|
||||
let first = parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap();
|
||||
let mono =
|
||||
first.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_)));
|
||||
assert!(mono, "the box-drawing character falls back to JetBrains Mono");
|
||||
let first = outlines(first);
|
||||
GONE.store(true, Ordering::SeqCst);
|
||||
let second = outlines(parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap());
|
||||
let _ = std::fs::remove_file(path());
|
||||
assert!(first.iter().any(|p| !p.subpaths.is_empty()), "the text has outlines");
|
||||
assert!(second == first, "the second import set the character in the same fallback face");
|
||||
}
|
||||
|
||||
/// #9671: the kept `cmap` is the one usvg reads (the last such record of a linear walk), also
|
||||
/// in a font whose table directory isn't sorted by tag, where a binary search misses it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_fallback_search_reads_the_cmap_of_an_unsorted_font() {
|
||||
fn inter_and_unsorted_mono_on_disk() -> Arc<usvg::fontdb::Database> {
|
||||
let mut font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec();
|
||||
let count = usize::from(u16::from_be_bytes([font[4], font[5]]));
|
||||
// The `cmap` record moves to the end of the directory.
|
||||
let mut records: Vec<Vec<u8>> = font[12..12 + count * 16].chunks(16).map(<[u8]>::to_vec).collect();
|
||||
let at = records.iter().position(|r| r.starts_with(b"cmap")).unwrap();
|
||||
let cmap = records.remove(at);
|
||||
records.push(cmap);
|
||||
font.splice(12..12 + count * 16, records.concat());
|
||||
let raw = ttf_parser::RawFace::parse(&font, 0).unwrap();
|
||||
assert!(raw.table(ttf_parser::Tag::from_bytes(b"cmap")).is_none(), "a binary search misses the cmap");
|
||||
assert!(raw.table(ttf_parser::Tag::from_bytes(b"name")).is_some(), "fontdb still finds the name");
|
||||
let path = std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id()));
|
||||
std::fs::write(&path, font).unwrap();
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec());
|
||||
db.load_font_file(&path).unwrap();
|
||||
Arc::new(db)
|
||||
}
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">I\xE2\x94\x80I</text></svg>";
|
||||
let parsed = parse_with(svg, inter_and_unsorted_mono_on_disk).unwrap();
|
||||
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id())));
|
||||
let mono =
|
||||
parsed.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_)));
|
||||
assert!(mono, "the box-drawing character falls back to JetBrains Mono");
|
||||
}
|
||||
|
||||
/// #9406: moving a font collection into memory keeps its faces in their order, so the
|
||||
/// fallback search for a later text still finds the face it found on disk first.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result<Document<'i>
|
||||
if opts.allow_dtd && internal_subset(text.as_bytes()) {
|
||||
return Err("internal DTD subsets aren't supported".into());
|
||||
}
|
||||
if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH) > MAX_DEPTH {
|
||||
if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH).map_err(|e| e.to_string())? > MAX_DEPTH {
|
||||
return Err(format!("elements are nested more than {MAX_DEPTH} levels deep"));
|
||||
}
|
||||
Document::parse_with_options(text, opts).map_err(|e| e.to_string())
|
||||
|
||||
@@ -411,6 +411,53 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() {
|
||||
assert_eq!(r.document.layers.len(), 1);
|
||||
}
|
||||
|
||||
/// The parser expands a DTD's internal entities after the depth check: an entity holding
|
||||
/// markup, or a quote in a DTD comment or declaration that made the check skip ahead, hid 20 000 nested groups
|
||||
/// from it and overflowed the stack again. Both are errors now; same small stack as above.
|
||||
#[test]
|
||||
fn a_dtd_cannot_hide_nesting_from_the_depth_check() {
|
||||
let deep = |open: &str, close: &str| format!("{}{}", open.repeat(20_000), close.repeat(20_000));
|
||||
let with_dtd = |subset: &str, body: &str| format!("<!DOCTYPE svg [{subset}]><svg {NS} width=\"8\" height=\"8\">{body}</svg>");
|
||||
let cases = [
|
||||
("entity", with_dtd(&format!("<!ENTITY a \"{}\">", deep("<g>", "</g>")), "&a;")),
|
||||
("single-quoted entity", with_dtd(&format!("<!ENTITY a '{}'>", deep("<g>", "</g>")), "&a;")),
|
||||
("quote in a comment", with_dtd("<!-- ' -->", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
|
||||
("quote in an element declaration", with_dtd("<!ELEMENT x '>", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
|
||||
(
|
||||
"entity between quoted attribute lists",
|
||||
with_dtd(&format!("<!ATTLIST svg x CDATA '><!ENTITY a \"{}\"><!ATTLIST svg y CDATA '>", deep("<g>", "</g>")), "&a;"),
|
||||
),
|
||||
("entity between quoted notations", with_dtd(&format!("<!NOTATION n SYSTEM '><!ENTITY a \"{}\"><!NOTATION m SYSTEM '>", deep("<g>", "</g>")), "&a;")),
|
||||
];
|
||||
std::thread::Builder::new()
|
||||
.stack_size(512 * 1024)
|
||||
.spawn(move || {
|
||||
for (name, svg) in &cases {
|
||||
let gz = gzip(svg.as_bytes());
|
||||
for bytes in [svg.as_bytes(), gz.as_slice()] {
|
||||
let e = import("deep.svg", bytes).err().unwrap_or_else(|| panic!("{name} opened"));
|
||||
assert!(matches!(&e, IoError::Svg(_)), "{name}: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Text-only entities, as Illustrator declares its namespaces, still open, `<` included:
|
||||
/// the parser keeps a character reference in an entity as text.
|
||||
#[test]
|
||||
fn text_entities_in_a_dtd_still_open() {
|
||||
let r = open(
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\
|
||||
<!ENTITY ns_svg \"http://www.w3.org/2000/svg\"> <!-- a ' and a <g> --> <?pi > ?> <!ATTLIST svg data-x CDATA '<'> <!ENTITY red '#ff0000'> <!ENTITY c \"© <g>\"> ]>\
|
||||
<svg xmlns=\"&ns_svg;\" width=\"8\" height=\"8\"><title>&c;</title><rect width=\"8\" height=\"8\" fill=\"&red;\"/></svg>",
|
||||
);
|
||||
assert_eq!(r.document.layers.len(), 1);
|
||||
assert!(close(px(&r.document, 4, 4), [1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// The depth check reads the markup, not the parser's tree: it must not count `<` in comments,
|
||||
/// CDATA, processing instructions or the DOCTYPE, nor end a tag at a `>` inside quotes.
|
||||
#[test]
|
||||
|
||||
@@ -313,6 +313,8 @@ impl PhotocraftApp {
|
||||
self.ui.selected_path = None;
|
||||
}
|
||||
self.path_document = document;
|
||||
// A shape box handle drag belongs to its document, revision and tool.
|
||||
crate::shape_bounds_box::cancel_stale(self);
|
||||
vector_ui::cancel_stale_pen(self);
|
||||
crate::direct_select::cancel_stale(self);
|
||||
canvas::cancel_stale_vector_drag(self);
|
||||
|
||||
@@ -637,6 +637,8 @@ impl PhotocraftApp {
|
||||
// The job locked the document, but record the revision that was written.
|
||||
st.saved_revision = revision;
|
||||
app.remember_stamp(doc, path);
|
||||
// History Options › Automatically Create New Snapshot When Saving (#2966).
|
||||
photocraft_engine::history_options_cmds::snapshot_on_save(&mut app.session);
|
||||
}
|
||||
// "Save Document" script events and File › Generate › Image Assets.
|
||||
if hooks && let Some(r) = photocraft_engine::automate_cmds::document_saved(&mut app.session, doc) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The Artboard tool's Add New Artboard placing mode (#11386): with the mode on, a click on empty
|
||||
//! canvas puts a board of the bar's size with its top-left corner at the click.
|
||||
|
||||
use egui::Modifiers;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use crate::state::Tool;
|
||||
|
||||
/// The measured document: two boards, the 750 x 1334 one selected, the other one apart from it.
|
||||
fn app_with_selected_board() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
|
||||
app.run("layer.new.artboard", json!({"rect": [2000, 0, 300, 300]})).unwrap();
|
||||
app.run("layer.new.artboard", json!({"rect": [900, 0, 750, 1334]})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
app.ui.tool = Tool::Artboard;
|
||||
app
|
||||
}
|
||||
|
||||
fn click(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
let m = Modifiers::NONE;
|
||||
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m);
|
||||
tool_event(app, ToolEvent::Up { x, y }, m);
|
||||
}
|
||||
|
||||
fn boards(app: &PhotocraftApp) -> Vec<Rect> {
|
||||
app.session.active().unwrap().doc.artboards().iter().map(|b| b.2.rect).collect()
|
||||
}
|
||||
|
||||
fn last_step(app: &PhotocraftApp) -> Option<String> {
|
||||
app.session.active().unwrap().history.entries().last().cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_with_the_mode_on_puts_a_bar_sized_board_at_the_click() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
let before = boards(&app);
|
||||
click(&mut app, 222.0, 600.0);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
let after = boards(&app);
|
||||
assert_eq!(after.len(), before.len() + 1);
|
||||
// 750 x 1334, top-left at the click, over the selected board (it did not move aside).
|
||||
assert_eq!(after.last().copied(), Some(Rect::new(222, 600, 972, 1934)));
|
||||
assert_eq!(after.get(..before.len()), Some(before.as_slice()));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.last().map(|l| l.name.as_str()), Some("Artboard 3"));
|
||||
assert_eq!(st.active_layer, Some(st.doc.artboards().last().unwrap().0));
|
||||
assert_eq!(last_step(&app).as_deref(), Some("New Artboard"));
|
||||
// One undo step.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(boards(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_click_is_rounded_to_whole_pixels() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
click(&mut app, 222.4, 600.6);
|
||||
assert_eq!(boards(&app).last().copied(), Some(Rect::new(222, 601, 972, 1935)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_stays_on_after_a_board_is_placed() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
click(&mut app, 222.0, 600.0);
|
||||
assert!(app.ui.artboard_tool.adding);
|
||||
// The next click is another placement, at the new selected board's size.
|
||||
click(&mut app, 3000.0, 100.0);
|
||||
assert_eq!(boards(&app).last().copied(), Some(Rect::new(3000, 100, 3750, 1434)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_with_the_mode_off_still_places_nothing() {
|
||||
let mut app = app_with_selected_board();
|
||||
let before = boards(&app);
|
||||
click(&mut app, 222.0, 600.0);
|
||||
assert_eq!(boards(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_board_the_click_uses_the_canvas_size() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.tool = Tool::Artboard;
|
||||
app.ui.artboard_tool.adding = true;
|
||||
click(&mut app, 40.0, 50.0);
|
||||
assert_eq!(boards(&app), vec![Rect::new(40, 50, 840, 650)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_with_the_mode_on_still_draws_the_dragged_board() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
let m = Modifiers::NONE;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 80.0, y: 70.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 120.0, y: 110.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 110.0 }, m);
|
||||
assert_eq!(boards(&app).last().copied(), Some(Rect::new(20, 30, 120, 110)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_an_existing_board_selects_it_and_places_nothing() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
let before = boards(&app);
|
||||
click(&mut app, 2100.0, 100.0);
|
||||
assert_eq!(boards(&app), before);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.active_layer, Some(st.doc.artboards()[0].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_tool_turns_the_mode_off() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
assert_eq!(serde_json::to_value(&app.ui.artboard_tool).unwrap()["adding"], json!(true));
|
||||
app.ui.tool = Tool::Move;
|
||||
click(&mut app, 3000.0, 100.0);
|
||||
assert!(!app.ui.artboard_tool.adding);
|
||||
app.ui.tool = Tool::Artboard;
|
||||
let before = boards(&app);
|
||||
click(&mut app, 3000.0, 100.0);
|
||||
assert_eq!(boards(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_inspect_reports_the_mode_on_and_off() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_selected_board();
|
||||
let adding = |app: &mut PhotocraftApp| {
|
||||
let (req, _) = crate::control::ControlRequest::new("ui.inspect", json!({}));
|
||||
let crate::control::Outcome::Done(v) = crate::control::handle(app, &ctx, &req) else { panic!("inspect") };
|
||||
v["result"]["artboardTool"]["adding"].clone()
|
||||
};
|
||||
assert_eq!(adding(&mut app), json!(false));
|
||||
app.ui.artboard_tool.adding = true;
|
||||
assert_eq!(adding(&mut app), json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_another_tool_and_coming_back_leaves_the_mode_off() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
// Both changes in one pass, with no pointer or options-bar call between them.
|
||||
crate::tool_memory::select(&mut app, Tool::Move);
|
||||
crate::tool_memory::select(&mut app, Tool::Artboard);
|
||||
assert!(!app.ui.artboard_tool.adding);
|
||||
let before = boards(&app);
|
||||
click(&mut app, 222.0, 600.0);
|
||||
assert_eq!(boards(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_add_new_artboard_button_toggles_the_mode_and_adds_no_board() {
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.tool = Tool::Artboard;
|
||||
let before = boards(&app);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 200.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
crate::panels::options_bar(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.artboard_tool.adding);
|
||||
h.get_by_label("Add New Artboard").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.artboard_tool.adding, "the first click turns the mode on");
|
||||
assert_eq!(boards(h.state()), before);
|
||||
h.get_by_label("Add New Artboard").click();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.artboard_tool.adding, "the second click turns it off");
|
||||
assert_eq!(boards(h.state()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_shortcuts_away_and_back_leave_the_mode_off() {
|
||||
let mut app = app_with_selected_board();
|
||||
app.ui.artboard_tool.adding = true;
|
||||
let ctx = egui::Context::default();
|
||||
let press = |app: &mut PhotocraftApp, modifiers| {
|
||||
let key = egui::Key::V;
|
||||
let raw = egui::RawInput {
|
||||
events: vec![
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.begin_pass(raw);
|
||||
crate::shortcuts::handle(app, &ctx);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
};
|
||||
// Shift+V leaves the Artboard tool for Move, and Shift+V again comes back, with no pointer or options-bar call between.
|
||||
press(&mut app, Modifiers::SHIFT);
|
||||
assert_eq!(app.ui.tool, Tool::Move);
|
||||
press(&mut app, Modifiers::SHIFT);
|
||||
assert_eq!(app.ui.tool, Tool::Artboard);
|
||||
assert!(!app.ui.artboard_tool.adding);
|
||||
let before = boards(&app);
|
||||
click(&mut app, 222.0, 600.0);
|
||||
assert_eq!(boards(&app), before);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The Artboard tool's first drag takes the Background in as "Layer 0" (#11387): the drag over
|
||||
//! the measured 800 x 600 document gives Artboard 1 (holding Layer 0) and Red at the top level.
|
||||
|
||||
use egui::Modifiers;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use crate::state::Tool;
|
||||
|
||||
#[test]
|
||||
fn the_tools_first_drag_moves_the_background_into_the_board() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
|
||||
app.run("layer.new.layer", json!({"name": "Red"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 150})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app.ui.tool = Tool::Artboard;
|
||||
let m = Modifiers::NONE;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 400.0, y: 300.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 500.0, y: 400.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 600.0, y: 500.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 600.0, y: 500.0 }, m);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
let st = app.session.active().unwrap();
|
||||
let top: Vec<&str> = st.doc.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(top, ["Red", "Artboard 1"]);
|
||||
assert!(st.doc.background().is_none());
|
||||
let board = st.doc.layers.last().unwrap();
|
||||
let kids: Vec<&str> = board.children().unwrap().iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(kids, ["Layer 0"]);
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), (800, 600));
|
||||
assert_eq!(st.history.entries().last().map(String::as_str), Some("New Artboard"));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! #5532: the Layers panel's Blend Mode list draws a line between Photoshop's six groups of modes:
|
||||
//! Normal, Dissolve | Darken … Darker Color | Lighten … Lighter Color | Overlay … Hard Mix |
|
||||
//! Difference … Divide | Hue … Luminosity.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Pos2, Rect, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::BlendMode;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// The app at 1440×900 with a new document and a pixel layer above the Background, active.
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// The Layers panel's dropdown: the lowest one (the options bar and the title bar have some too).
|
||||
fn dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect {
|
||||
h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap()
|
||||
}
|
||||
|
||||
/// The open list's rect (the one foreground area: no other popup is open).
|
||||
fn open_list(h: &Harness<'_, PhotocraftApp>) -> Option<Rect> {
|
||||
h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)))
|
||||
}
|
||||
|
||||
fn blend(h: &Harness<'_, PhotocraftApp>) -> BlendMode {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().blend
|
||||
}
|
||||
|
||||
fn steps(h: &Harness<'_, PhotocraftApp>) -> usize {
|
||||
h.state().session.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
/// Presses at `at`, holds for `held` frames and releases there.
|
||||
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, held: usize) {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for (pressed, frames) in [(true, held), (false, 4)] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
h.run_steps(frames);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layers_blend_mode_list_has_a_line_between_each_group() {
|
||||
let mut h = harness();
|
||||
let at = dropdown(&h).center();
|
||||
click(&mut h, at, 1);
|
||||
assert!(open_list(&h).is_some(), "the list is open");
|
||||
// Each mode's row in the open list: its label's last node (the box shows the chosen one's
|
||||
// first). Rows scrolled out of the list keep their place in it, so the gaps hold for all.
|
||||
let rows: Vec<Rect> = BlendMode::LAYER_MODES
|
||||
.iter()
|
||||
.map(|m| h.get_all_by_label(m.label()).map(|n| n.rect()).next_back().unwrap_or_else(|| panic!("{m:?} is in the list")))
|
||||
.collect();
|
||||
let gaps: Vec<f32> = rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect();
|
||||
let row_gap = gaps.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let lines: Vec<(BlendMode, BlendMode)> =
|
||||
gaps.iter().enumerate().filter(|(_, g)| **g > row_gap + 2.0).map(|(i, _)| (BlendMode::LAYER_MODES[i], BlendMode::LAYER_MODES[i + 1])).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
(BlendMode::Dissolve, BlendMode::Darken),
|
||||
(BlendMode::DarkerColor, BlendMode::Lighten),
|
||||
(BlendMode::LighterColor, BlendMode::Overlay),
|
||||
(BlendMode::HardMix, BlendMode::Difference),
|
||||
(BlendMode::Divide, BlendMode::Hue),
|
||||
],
|
||||
"row gaps {gaps:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain click on the Layers panel's Blend Mode box at 1440×900, released where it was pressed
|
||||
/// (after one frame, or after a few as a person's click takes), opens the list beside the box and
|
||||
/// chooses nothing: the list doesn't open over the box, where the release would pick a row
|
||||
/// (#5495 review of a taller list).
|
||||
#[test]
|
||||
fn a_click_on_the_blend_mode_box_opens_the_list_and_changes_nothing() {
|
||||
for held in [6, 1] {
|
||||
let mut h = harness();
|
||||
let undo = steps(&h);
|
||||
let button = dropdown(&h);
|
||||
click(&mut h, button.center(), held);
|
||||
assert_eq!((blend(&h), steps(&h)), (BlendMode::Normal, undo), "held {held}: the click chose nothing");
|
||||
let Some(list) = open_list(&h) else { panic!("held {held}: the list is open") };
|
||||
assert!(!list.intersects(button.shrink(1.0)), "held {held}: the list {list:?} leaves the box {button:?} free");
|
||||
assert!(list.top() >= 0.0 && list.bottom() <= 900.0, "held {held}: the list {list:?} is in the window");
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn choices() -> Vec<((Option<BlendMode>, bool), &'static str)> {
|
||||
fn blend_items(behind: bool, clear: bool) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for m in BlendMode::LAYER_MODES {
|
||||
if matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) {
|
||||
if crate::panels::starts_blend_group(m) {
|
||||
out.push(Item::Separator);
|
||||
}
|
||||
out.push(Item::Blend(m));
|
||||
|
||||
@@ -45,7 +45,7 @@ mod selection;
|
||||
pub mod snap_ui;
|
||||
mod start_screen;
|
||||
mod strokes;
|
||||
mod tabs;
|
||||
pub(crate) mod tabs;
|
||||
mod textures;
|
||||
pub(super) mod tile_overlay;
|
||||
pub mod tool_cursor;
|
||||
@@ -1422,6 +1422,9 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C
|
||||
// corner badge, as Photoshop's corner radius cursor.
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
|
||||
crate::icons::cursor_badge(ctx, "scan", p);
|
||||
} else if let Some(c) = response.hover_pos().and_then(|p| crate::shape_bounds_box::cursor(app, xf, xf.to_doc(p))) {
|
||||
// Over a handle of the box round several selected shapes: its scale cursor.
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if let Some((r, p, c)) =
|
||||
response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
|
||||
{
|
||||
@@ -1699,9 +1702,14 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie
|
||||
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
|
||||
}
|
||||
// A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the
|
||||
// drag's next move then extends into what scrolled in.
|
||||
// drag's next move then extends into what scrolled in. The crop tools keep their own drags:
|
||||
// they scroll only while the press is the canvas's own (not a panel slider or a scrollbar held
|
||||
// while a stale crop drag lingers) and the crop tool has it (not a temporary Zoom, which
|
||||
// freezes the drag).
|
||||
let crop_drag =
|
||||
response.is_pointer_button_down_on() && tool == app.ui.tool && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app));
|
||||
if primary
|
||||
&& app.drag.is_some()
|
||||
&& (app.drag.is_some() || crop_drag)
|
||||
&& tool != Tool::Hand
|
||||
&& !middle
|
||||
&& let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten())
|
||||
@@ -1962,6 +1970,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::crop_straighten::set_held(app, !ctx.text_edit_focused() && ui.input(|i| i.modifiers.command));
|
||||
// A tab switched this frame drops the other document's pending crop (#1918).
|
||||
crate::crop_ui::cancel_stale(app);
|
||||
// A tool picked mid-drag ends the crop gesture before the frame sees the switch, which would
|
||||
// otherwise drop the untouched default frame under the box being drawn (#9223). The Hand and
|
||||
// Zoom never reach `tool_event`, so this is the one place that sees every tool.
|
||||
crate::crop_ui::end_drag_on_leave(app);
|
||||
crate::crop_ui::ensure_frame(app);
|
||||
let mut drawing = crate::crop_ui::active(app);
|
||||
if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) {
|
||||
|
||||
@@ -184,9 +184,9 @@ const ENDS_TYPE_EDIT: [&str; 5] =
|
||||
pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor";
|
||||
|
||||
/// The guide menu's Edit Selected Guides row: New Guide's dialog on the selected guide.
|
||||
pub const GUIDE_EDIT: &str = "guides.editSelected";
|
||||
pub const GUIDE_EDIT: &str = "view.editSelectedGuides";
|
||||
/// The guide menu's Clear Selected Guides row.
|
||||
pub const GUIDE_CLEAR: &str = "guides.clearSelected";
|
||||
pub const GUIDE_CLEAR: &str = "view.clearSelectedGuides";
|
||||
|
||||
/// Photoshop's guide menu (right-click on a guide), in its order. Its two Selected Guides rows act
|
||||
/// on the guide the right-click selected; the rest are the View menu's.
|
||||
@@ -753,14 +753,9 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui
|
||||
true
|
||||
}
|
||||
|
||||
/// A guide row: the two Selected Guides rows act on the selected guide; the rest are the View
|
||||
/// menu's commands.
|
||||
/// A guide row: every row is a View › Guides command, the two Selected Guides ones included.
|
||||
fn choose_guide(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> {
|
||||
match command {
|
||||
GUIDE_EDIT => crate::new_guide_ui::open_edit(app).map(|_| ()),
|
||||
GUIDE_CLEAR => crate::guide_select::delete_selected(app).map(|_| ()),
|
||||
_ => crate::menus::invoke(app, ctx, command, json!({})).map(|_| ()),
|
||||
}
|
||||
crate::menus::invoke(app, ctx, command, json!({})).map(|_| ())
|
||||
}
|
||||
|
||||
/// A pasteboard row: set the colour, or open the Color Picker on the custom one.
|
||||
|
||||
@@ -27,6 +27,9 @@ pub struct Drag {
|
||||
/// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`), as
|
||||
/// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`).
|
||||
pub sel_move: Option<bool>,
|
||||
/// The pointer, before snapping, has been half a pixel or more from the press: the gesture is
|
||||
/// not a click even if it comes back there (`tool_input::selection_release`, #11392).
|
||||
pub left_press: bool,
|
||||
/// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231);
|
||||
/// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`.
|
||||
pub sel_axis: Option<[f64; 2]>,
|
||||
@@ -34,6 +37,10 @@ pub struct Drag {
|
||||
/// Freeform Pen and Path Selection gestures belong to their source document and target.
|
||||
pub(super) vector_owner: Option<VectorGestureOwner>,
|
||||
pub(super) pen_owner: Option<crate::vector_ui::PenOwner>,
|
||||
/// The document had a selection (one with content) when the drag was pressed. A modifier held
|
||||
/// at the press picks the selection mode only then; without one it constrains the shape from
|
||||
/// the first move (#11389). `true` until recorded, which keeps that rule for drags that don't.
|
||||
pub selection_at_press: bool,
|
||||
}
|
||||
|
||||
impl Drag {
|
||||
@@ -50,13 +57,21 @@ impl Drag {
|
||||
released: egui::Modifiers::NONE,
|
||||
reposition: false,
|
||||
sel_move: None,
|
||||
left_press: false,
|
||||
sel_axis: None,
|
||||
lasso: None,
|
||||
vector_owner: None,
|
||||
pen_owner: None,
|
||||
selection_at_press: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record whether the document has a selection at the press; an empty one counts as none, as
|
||||
/// `tool_feedback::document_selection_mode` counts it (#11389).
|
||||
pub(crate) fn capture_selection_at_press(&mut self, app: &PhotocraftApp) {
|
||||
self.selection_at_press = app.session.active().and_then(|st| st.doc.selection.as_ref()).is_some_and(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
|
||||
/// Vector drags capture their owner at the press, the Freeform Pen also its destination, before
|
||||
/// later ⌥-clicks or a temporary Direct Selection can finish them.
|
||||
pub(crate) fn capture_vector_owners(&mut self, app: &PhotocraftApp) {
|
||||
@@ -92,13 +107,22 @@ impl Drag {
|
||||
self.released.alt |= !mods.alt;
|
||||
}
|
||||
|
||||
/// ⇧ (square / circle) and ⌥ (from the centre) for a marquee drag. A modifier held at the press
|
||||
/// picks the selection mode instead (add / subtract, #188) until it is released and pressed
|
||||
/// again, as in Photoshop.
|
||||
/// ⇧ (square / circle) and ⌥ (from the centre) for a marquee drag. With a selection at the
|
||||
/// press, a modifier held at the press picks the selection mode instead (add / subtract, #188)
|
||||
/// until it is released and pressed again, as in Photoshop; without one it constrains the
|
||||
/// shape from the first move (#11389).
|
||||
pub fn marquee_mods(&self) -> (bool, bool) {
|
||||
let fresh = |now: bool, at_press: bool, released: bool| now && (!at_press || released);
|
||||
let picks_mode = self.selection_at_press;
|
||||
let fresh = |now: bool, at_press: bool, released: bool| now && (!at_press || released || !picks_mode);
|
||||
(fresh(self.live.shift, self.modifiers.shift, self.released.shift), fresh(self.live.alt, self.modifiers.alt, self.released.alt))
|
||||
}
|
||||
|
||||
/// ⇧ held since the press with no selection: the square takes the drag's shorter side, as the
|
||||
/// reference application does (a 200 × 100 drag selects 100 × 100, #11389). ⇧ pressed during
|
||||
/// the drag keeps the longer side (not measured, so as before).
|
||||
fn square_from_press(&self) -> bool {
|
||||
!self.selection_at_press && self.modifiers.shift && !self.released.shift && self.live.shift
|
||||
}
|
||||
}
|
||||
|
||||
/// The marquee dragged from `d.start` to `end` as two document-space corners: the options-bar
|
||||
@@ -113,7 +137,13 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -
|
||||
let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end };
|
||||
return (a, [a[0] + w, a[1] + h]);
|
||||
}
|
||||
let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end);
|
||||
let e = if shift && d.square_from_press() && !matches!(o.marquee_style.as_str(), "fixedRatio" | "fixedSize") {
|
||||
let (dx, dy) = (end[0] - d.start[0], end[1] - d.start[1]);
|
||||
let m = dx.abs().min(dy.abs());
|
||||
[d.start[0] + m.copysign(dx), d.start[1] + m.copysign(dy)]
|
||||
} else {
|
||||
crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end)
|
||||
};
|
||||
if !alt {
|
||||
return (d.start, e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,19 @@ use super::textures::shown_as_document;
|
||||
use super::{PhotocraftApp, Tool, json};
|
||||
|
||||
pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
finish(app, d, None);
|
||||
}
|
||||
|
||||
/// [`finish_gesture`] for a pointer release. `click`: the release, before snapping, came back
|
||||
/// within half a pixel of the press (`tool_input`). A selection drag that snapping pulled back onto
|
||||
/// its press point is not one (#11392).
|
||||
pub(crate) fn finish_release(app: &mut PhotocraftApp, d: Drag, click: bool) {
|
||||
finish(app, d, Some(click));
|
||||
}
|
||||
|
||||
/// `click`: whether the gesture was a click, when the caller saw the release before snapping;
|
||||
/// otherwise the end point decides.
|
||||
fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option<bool>) {
|
||||
// Control can select a different tool and release before the next frame cancels the drag.
|
||||
// Cancel before even recording a Shift-stroke anchor, including the preview fallback paths.
|
||||
if d.tool == Tool::MixerBrush && app.ui.tool != Tool::MixerBrush {
|
||||
@@ -20,9 +33,9 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]);
|
||||
end[0] = sx;
|
||||
end[1] = sy;
|
||||
if let Some(floating) = d.sel_move {
|
||||
finish_selection_drag(app, floating, d.start, [end[0], end[1]]);
|
||||
return;
|
||||
if d.sel_move.is_some() {
|
||||
let Some(line) = finish_selection_release(app, &mut d, end, click) else { return };
|
||||
end = line;
|
||||
}
|
||||
// Where the next ⇧-click line starts.
|
||||
if crate::stroke_constraint::connects(d.tool)
|
||||
@@ -153,6 +166,41 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The release of selection drag `d` at `end` (`click`: see [`finish`]). A Single Row / Single
|
||||
/// Column click inside the selection or on a floating piece selects a line, as it does elsewhere,
|
||||
/// instead of deselecting or dropping as other marquees do (#11392): the point it selects at, for
|
||||
/// the tool's own release to go on with. `None`: the release is done. A click ends here unsnapped
|
||||
/// (`tool_input`), so it is never a move; the line snaps as a click outside the selection does (its
|
||||
/// press began as a move, which keeps the press point).
|
||||
fn finish_selection_release(app: &mut PhotocraftApp, d: &mut Drag, end: [f64; 3], click: Option<bool>) -> Option<[f64; 3]> {
|
||||
let floating = d.sel_move?;
|
||||
let unmoved = (end[0] - d.start[0]).round() == 0.0 && (end[1] - d.start[1]).round() == 0.0;
|
||||
let click = click.unwrap_or(unmoved);
|
||||
if unmoved && !click {
|
||||
// A drag that snapping pulled back where it started leaves the selection as it was. Its
|
||||
// preview of a floating piece goes too: nothing changed for it to go stale with, and a
|
||||
// Move-tool drag's release (`move_ui::finish`) is not reached. It is rebuilt as needed.
|
||||
app.move_preview = None;
|
||||
return None;
|
||||
}
|
||||
if !click || !matches!(d.tool, Tool::SingleRowMarquee | Tool::SingleColumnMarquee) {
|
||||
finish_selection_drag(app, floating, d.start, [end[0], end[1]]);
|
||||
return None;
|
||||
}
|
||||
// The piece drops where it is first, as a press off it drops it (`tool_input`).
|
||||
if floating
|
||||
&& app.session.active().and_then(photocraft_engine::float_cmds::floating).is_some()
|
||||
&& let Err(e) = app.run("select.drop", json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
return None;
|
||||
}
|
||||
let [x, y] = crate::snap_ui::snap_click(app, d.start, d.live);
|
||||
d.points.push([x, y, end[2]]);
|
||||
Some([x, y, end[2]])
|
||||
}
|
||||
|
||||
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
|
||||
/// The cursor badge announces the same mode (`tool_feedback`).
|
||||
pub(crate) fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
|
||||
@@ -161,6 +209,8 @@ pub(crate) fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'stati
|
||||
|
||||
/// Shift constrains a Polygonal Lasso segment to Photoshop's 45° directions.
|
||||
/// Work in document coordinates so zoom, view rotation and mirroring cannot skew it.
|
||||
/// A diagonal keeps the shorter of the two offsets on both axes, as Photoshop does (#11393);
|
||||
/// a horizontal or vertical snap keeps the segment's length.
|
||||
pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64; 2] {
|
||||
let dx = point[0] - from[0];
|
||||
let dy = point[1] - from[1];
|
||||
@@ -171,7 +221,12 @@ pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64
|
||||
if length == 0.0 {
|
||||
return point;
|
||||
}
|
||||
let angle = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
|
||||
let octant = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round();
|
||||
let angle = octant * std::f64::consts::FRAC_PI_4;
|
||||
if octant.rem_euclid(2.0) == 1.0 {
|
||||
let m = dx.abs().min(dy.abs());
|
||||
return [from[0] + m * angle.cos().signum(), from[1] + m * angle.sin().signum()];
|
||||
}
|
||||
[from[0] + length * angle.cos(), from[1] + length * angle.sin()]
|
||||
}
|
||||
|
||||
@@ -246,7 +301,7 @@ pub fn commit_polygon(app: &mut PhotocraftApp) {
|
||||
if pts.len() >= 3 {
|
||||
let mode = if mode.is_empty() { selection_mode(app, egui::Modifiers::NONE).to_owned() } else { mode };
|
||||
let o = &app.ui.tool_options;
|
||||
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": o.feather}));
|
||||
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": o.feather, "polygonal": true}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,19 @@ pub(crate) fn moves_selected_pixels_with(app: &PhotocraftApp, tool: Tool) -> boo
|
||||
/// time, or with `copy` (⌥) floats a duplicate and leaves them; `copy` on a piece already floating
|
||||
/// drops it where it is and floats a copy of it. False when it failed (reported in the status bar).
|
||||
pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f64) -> bool {
|
||||
float_selected_as(app, copy, dx, dy, None)
|
||||
}
|
||||
|
||||
/// The start of a Move-tool drag of the selected pixels. An ⌥-drag copy is one "Duplicate" step,
|
||||
/// as in Photoshop (#11383); a plain drag is "Move".
|
||||
pub(crate) fn float_selected_for_drag(app: &mut PhotocraftApp, copy: bool) -> bool {
|
||||
float_selected_as(app, copy, 0.0, 0.0, copy.then(|| tl!("Duplicate")))
|
||||
}
|
||||
|
||||
fn float_selected_as(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f64, step_name: Option<&str>) -> bool {
|
||||
let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
|
||||
let dropped = if copy && floating { app.run("select.drop", json!({})) } else { Ok(json!(null)) };
|
||||
match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy}))) {
|
||||
match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy, "stepName": step_name}))) {
|
||||
Ok(_) => {
|
||||
crate::snap_ui::follow_cut(app);
|
||||
true
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(super) fn finish_selection_drag(app: &mut PhotocraftApp, floating: bool, sta
|
||||
(false, true) if app.session.is_enabled("select.deselect") => app.run("select.deselect", json!({})),
|
||||
(false, true) => return,
|
||||
(true, false) => app.run("select.float", json!({"dx": dx, "dy": dy})),
|
||||
(false, false) => app.run("select.transformSelection", json!({"dx": dx, "dy": dy})),
|
||||
(false, false) => app.run("select.transformSelection", json!({"dx": dx, "dy": dy, "stepName": "Move Selection"})),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e;
|
||||
|
||||
@@ -202,6 +202,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
|
||||
let tool = crate::canvas::event_tool(app, mods);
|
||||
let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods);
|
||||
let crop_tol = crate::crop_ui::tolerance(app);
|
||||
let straightens = tool == Tool::Crop && app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command);
|
||||
let gesture = if let Some(t) = &app.ui.transform {
|
||||
use crate::transform_tool::Hit;
|
||||
let h = crate::transform_tool::box_hit(app, t, p, crate::transform_tool::handle_tolerance(app));
|
||||
@@ -231,6 +232,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
|
||||
} else if tool == Tool::Move || moves_layer {
|
||||
let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default();
|
||||
moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude))
|
||||
} else if straightens {
|
||||
// A straighten line (`crop_straighten::pointer` takes the press before the frame's drags)
|
||||
// snaps its ends as points wherever it starts, never by the offset of a frame or image it
|
||||
// doesn't move.
|
||||
Some((Gesture::Point, Vec::new()))
|
||||
} else if tool == Tool::Crop
|
||||
&& (crate::crop_mode::classic(app) || app.crop.default_frame)
|
||||
&& let Some(rect) =
|
||||
@@ -297,7 +303,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
|
||||
} else {
|
||||
SnapTargets::default()
|
||||
};
|
||||
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false });
|
||||
// Windows Ctrl both starts a straighten line and is held while it is drawn: the override then
|
||||
// covers the press too, or the line's start would snap and its end not.
|
||||
let disabled = straightens && override_held(mods);
|
||||
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled });
|
||||
}
|
||||
|
||||
/// Snap a guide drag that ⌘ with a selection tool starts at `p` (`canvas::command_guide_at`,
|
||||
@@ -308,6 +317,19 @@ pub fn begin_guide(app: &mut PhotocraftApp, p: [f64; 2]) {
|
||||
app.prefs_rt.snap = Some(ActiveSnap { gesture: Gesture::Guide, start: p, targets, smart: SnapTargets::default(), disabled: false });
|
||||
}
|
||||
|
||||
/// Snap `p` as a point press does (`Gesture::Point`), for a press that began as a selection move
|
||||
/// and ended as a click: a Single Row / Column Marquee click inside the selection selects the line
|
||||
/// at the snapped point, as one outside does (#11392). Not to the selection's own bounds, which the
|
||||
/// click replaces (on a 1 px line they would pick the line beside it). Ctrl held (`mods`) turns
|
||||
/// snapping off, as during a drag.
|
||||
pub(crate) fn snap_click(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
|
||||
if !snap_on(app) || override_held(mods) {
|
||||
return p;
|
||||
}
|
||||
let targets = build(app, &[], false).filtered(|k| k != SnapKind::Selection);
|
||||
targets.snap_point(p, tolerance(app)).0
|
||||
}
|
||||
|
||||
/// Round to whole pixels when Preferences › Tools asks vector tools and transforms to snap to
|
||||
/// the pixel grid.
|
||||
fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
|
||||
@@ -524,6 +546,18 @@ fn follow_reposition(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a crop frame or one of its handles (released where it was pressed, the frame not
|
||||
/// moved) changes nothing, as a Move-tool click doesn't move the layer (`canvas::tool_event`):
|
||||
/// snapping the release would pull an edge within reach onto a nearby one.
|
||||
fn crop_click(app: &PhotocraftApp, p: [f64; 2]) -> bool {
|
||||
use crate::crop_ui::CropDrag;
|
||||
let Some(snap) = app.prefs_rt.snap.as_ref() else { return false };
|
||||
let Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) = app.crop.drag else {
|
||||
return false;
|
||||
};
|
||||
app.ui.tool == Tool::Crop && p == snap.start && app.ui.crop_rect == Some(rect)
|
||||
}
|
||||
|
||||
/// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it).
|
||||
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
|
||||
match ev {
|
||||
@@ -564,6 +598,11 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
|
||||
if app.prefs_rt.snap.is_none() {
|
||||
return ev;
|
||||
}
|
||||
if crop_click(app, [x, y]) {
|
||||
app.prefs_rt.snap = None;
|
||||
app.prefs_rt.snap_lines.clear();
|
||||
return ev;
|
||||
}
|
||||
follow_reposition(app);
|
||||
let disabled = override_for_gesture(app, mods);
|
||||
if let Some(s) = app.prefs_rt.snap.as_mut() {
|
||||
|
||||
@@ -93,6 +93,77 @@ fn drawing_a_new_crop_snaps_its_corner_not_the_default_frame() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A straighten line drawn inside an upright, edited frame levels the frame on the line as
|
||||
/// drawn: it isn't shifted by the offset that would snap the frame if the press moved it. The
|
||||
/// default mode moves the image there, which never shifted the line; it stays as it was.
|
||||
#[test]
|
||||
fn a_straighten_line_inside_the_frame_is_not_snapped_as_a_frame_move() {
|
||||
let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() };
|
||||
for (classic, armed, m) in [(true, true, egui::Modifiers::NONE), (true, false, mac_cmd), (false, true, egui::Modifiers::NONE)] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.views[0].zoom = 1.0;
|
||||
app.ui.views[0].fit_pending = false;
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.tool_options.crop_shield.classic_mode = classic;
|
||||
app.ui.extras.snap = true;
|
||||
app.ui.view.show.smart_guides = true;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]);
|
||||
app.crop.default_frame = false;
|
||||
if classic {
|
||||
// Without Straighten the press moves the frame and snaps its edges, as on main.
|
||||
crate::snap_ui::begin(&mut app, [30.0, 30.0], egui::Modifiers::NONE);
|
||||
let gesture = app.prefs_rt.snap.take().map(|s| s.gesture);
|
||||
assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 70.0] }));
|
||||
}
|
||||
app.crop.straighten.armed = armed;
|
||||
// Moving the frame by the line's (100, 8) would bring its centre 3 px from the
|
||||
// document's: that offset must not reach the line. Both ends are clear of every target.
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 38.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 38.0 }, m);
|
||||
let want = crate::crop_straighten::frame_angle([30.0, 30.0], [130.0, 38.0], 0.0).unwrap();
|
||||
let got = crate::crop_ui::angle(&app);
|
||||
assert!((got - want).abs() < 1e-9, "Classic Mode {classic}, armed {armed}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows Ctrl both starts a straighten line and is held while drawing it, which turns
|
||||
/// snapping off: the press stays where it was too, so the frame levels on the line as drawn,
|
||||
/// inside an edited frame, outside it and in the untouched default frame. ⌘ on the Mac is not
|
||||
/// the override: both ends snap.
|
||||
#[test]
|
||||
fn a_windows_ctrl_straighten_line_does_not_snap_its_start_either() {
|
||||
let win_ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() };
|
||||
for frame in [Some([20.0, 20.0, 120.0, 70.0]), Some([130.0, 20.0, 190.0, 70.0]), None] {
|
||||
for (m, start) in [(win_ctrl, [97.0, 30.0]), (mac_cmd, [100.0, 30.0])] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.views[0].zoom = 1.0;
|
||||
app.ui.views[0].fit_pending = false;
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.tool_options.crop_shield.classic_mode = true;
|
||||
app.ui.extras.snap = true;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
if let Some(r) = frame {
|
||||
app.ui.crop_rect = Some(r);
|
||||
app.crop.default_frame = false;
|
||||
}
|
||||
// The press is 3 px from the document's centre line; the end is clear of every target.
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 97.0, y: 30.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 180.0, y: 38.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 180.0, y: 38.0 }, m);
|
||||
let want = crate::crop_straighten::frame_angle(start, [180.0, 38.0], 0.0).unwrap();
|
||||
let got = crate::crop_ui::angle(&app);
|
||||
assert!((got - want).abs() < 1e-9, "frame {frame:?}, {m:?}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_tool_snaps_to_other_layer_edges_with_smart_guides() {
|
||||
let mut app = app_with_box();
|
||||
|
||||
@@ -327,25 +327,39 @@ fn tab_close_clicked(resp: &egui::Response, xresp: &egui::Response) -> bool {
|
||||
xresp.clicked() || xresp.middle_clicked() || resp.middle_clicked()
|
||||
}
|
||||
|
||||
/// All tab actions go through the same guarded File commands as the menu bar, including the
|
||||
/// Rows of the document tab menu after which a separator is drawn: after Move to New Window and
|
||||
/// after Open Document…, as in the reference (#11403).
|
||||
pub(crate) const TAB_MENU_SEPARATORS_AFTER: [usize; 2] = [4, 6];
|
||||
|
||||
pub(crate) type TabMenuItem = (&'static str, &'static str, serde_json::Value, bool);
|
||||
|
||||
/// All tab actions go through the same guarded commands as the menu bar, including the
|
||||
/// unsaved-changes prompt. The clicked tab is explicit even when another document is active.
|
||||
pub(super) fn tab_context_items(index: usize, count: usize, has_path: bool) -> [(&'static str, &'static str, serde_json::Value, bool); 4] {
|
||||
/// Order and separators follow the reference application (#11403); see [`TAB_MENU_SEPARATORS_AFTER`].
|
||||
pub(crate) fn tab_context_items(index: usize, count: usize, has_path: bool) -> [TabMenuItem; 8] {
|
||||
[
|
||||
("Consolidate All to Here", "window.arrange.consolidateAllToTabs", json!({}), count > 1),
|
||||
("Close", "file.close", json!({"document": index}), true),
|
||||
("Close Others", "file.closeOthers", json!({"document": index}), count > 1),
|
||||
("Close All", "file.closeAll", json!({}), true),
|
||||
("Close Others", "file.closeOthers", json!({"document": index}), count > 1),
|
||||
("Move to New Window", "window.arrange.floatInWindow", json!({"document": index}), true),
|
||||
("New Document…", "file.new", json!({}), true),
|
||||
("Open Document…", "file.open", json!({}), true),
|
||||
(crate::menus::reveal_label(), "file.revealInFinder", json!({"document": index}), has_path),
|
||||
]
|
||||
}
|
||||
|
||||
fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
pub(crate) fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for (label, id, params, enabled) in tab_context_items(index, count, has_path) {
|
||||
for (row, (label, id, params, enabled)) in tab_context_items(index, count, has_path).into_iter().enumerate() {
|
||||
if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() {
|
||||
ui.close();
|
||||
return Some((id, params));
|
||||
}
|
||||
if TAB_MENU_SEPARATORS_AFTER.contains(&row) {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
|
||||
@@ -935,20 +935,38 @@ fn navigator_damage_updates_match_a_full_thumbnail() {
|
||||
#[test]
|
||||
fn tab_context_uses_clicked_document_and_existing_close_commands() {
|
||||
let items = tab_context_items(2, 3, true);
|
||||
assert_eq!(items.iter().map(|(_, id, _, _)| *id).collect::<Vec<_>>(), ["file.close", "file.closeOthers", "file.closeAll", "file.revealInFinder"]);
|
||||
assert_eq!(items[0].2, json!({"document": 2}));
|
||||
assert_eq!(
|
||||
items.iter().map(|(_, id, _, _)| *id).collect::<Vec<_>>(),
|
||||
[
|
||||
"window.arrange.consolidateAllToTabs",
|
||||
"file.close",
|
||||
"file.closeAll",
|
||||
"file.closeOthers",
|
||||
"window.arrange.floatInWindow",
|
||||
"file.new",
|
||||
"file.open",
|
||||
"file.revealInFinder"
|
||||
]
|
||||
);
|
||||
assert_eq!(items[1].2, json!({"document": 2}));
|
||||
assert_eq!(items[2].2, json!({}));
|
||||
assert_eq!(items[3].2, json!({"document": 2}));
|
||||
assert!(items[3].3, "a saved document can be revealed in the file manager");
|
||||
assert!(!tab_context_items(2, 3, false)[3].3, "an unsaved document cannot");
|
||||
assert!(!tab_context_items(0, 1, false)[1].3);
|
||||
assert!(items.iter().all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some()));
|
||||
assert_eq!(items[2].2, json!({}));
|
||||
assert_eq!(items[7].2, json!({"document": 2}));
|
||||
assert!(items[7].3, "a saved document can be revealed in the file manager");
|
||||
assert!(!tab_context_items(2, 3, false)[7].3, "an unsaved document cannot");
|
||||
assert!(!tab_context_items(0, 1, false)[3].3);
|
||||
// The window.* rows and file.new / file.open are UI commands; the doc_tab_menu tests check them.
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.filter(|r| r.1.starts_with("file.close") || r.1 == "file.revealInFinder")
|
||||
.all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_context_reveal_names_the_platform_file_manager() {
|
||||
let (label, id, _, _) = &tab_context_items(0, 1, true)[3];
|
||||
let (label, id, _, _) = &tab_context_items(0, 1, true)[7];
|
||||
assert_eq!((*label, *id), (crate::menus::reveal_label(), "file.revealInFinder"));
|
||||
}
|
||||
|
||||
@@ -1099,7 +1117,7 @@ fn tab_close_others_prompts_for_unsaved_nonactive_document() {
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "name": "Edited"})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
let (_, id, params, _) = tab_context_items(0, 2, false)[1].clone();
|
||||
let (_, id, params, _) = tab_context_items(0, 2, false)[3].clone();
|
||||
crate::menus::invoke(&mut app, &egui::Context::default(), id, params).unwrap();
|
||||
assert!(app.discard.is_some(), "close others must ask before discarding the edited tab");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cancel_stale_vector_drag;
|
||||
use super::cursors::{alt_eyedropper, sample_eyedropper};
|
||||
use super::drag::Drag;
|
||||
use super::gesture::{finish_gesture, polygon_click, selection_mode};
|
||||
use super::gesture::{finish_release, polygon_click, selection_mode};
|
||||
use super::selection::finish_selection_drag;
|
||||
use super::strokes::{HOLD_REPEAT_MS, begin_live_stroke, feed_live_stroke, paint_target, snap_aliased_tip, stroke_params, strokes_live};
|
||||
use super::{PhotocraftApp, Tool, json, multiple_color_target};
|
||||
@@ -61,6 +61,12 @@ fn preflight_tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modi
|
||||
{
|
||||
crate::guide_select::clear(app);
|
||||
}
|
||||
// A tool key pressed mid-drag ends the crop gesture here: a later handler can take every event
|
||||
// of the new tool, and a drag left set keeps the frame from committing on leave. It ends as a
|
||||
// release would, so a box drawn over the untouched default frame is committed, not dropped (#9223).
|
||||
if app.ui.tool != Tool::Crop {
|
||||
crate::crop_ui::end_drag(app);
|
||||
}
|
||||
if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) {
|
||||
return None;
|
||||
}
|
||||
@@ -160,6 +166,11 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
|
||||
if crate::corner_radius::pointer(app, raw, mods) {
|
||||
return None;
|
||||
}
|
||||
// The box round several selected shape layers: its handles scale them (Path Selection and the
|
||||
// shape tools).
|
||||
if crate::shape_bounds_box::pointer(app, raw, mods) {
|
||||
return None;
|
||||
}
|
||||
// Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790).
|
||||
if crate::direct_select::pointer(app, ev, mods) {
|
||||
return None;
|
||||
@@ -276,7 +287,7 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
|
||||
// With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating
|
||||
// piece, from anywhere, as a marquee ⌘-drag does.
|
||||
if crate::move_ui::moves_selected_pixels_with(app, tool) {
|
||||
if crate::move_ui::float_selected(app, mods.alt, 0.0, 0.0) {
|
||||
if crate::move_ui::float_selected_for_drag(app, mods.alt) {
|
||||
let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false);
|
||||
d.sel_move = Some(true);
|
||||
app.drag = Some(d);
|
||||
@@ -392,8 +403,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
return;
|
||||
}
|
||||
// Marquee / lasso inside the selection: drag the outline, or ⌘-drag to cut the selected
|
||||
// pixels into a floating piece (`select.float`) and drag that.
|
||||
if let Some(cut) = selection_drag_kind(app, tool, [x, y], mods) {
|
||||
// pixels into a floating piece (`select.float`) and drag that. Inside is judged where the
|
||||
// press was before snapping, as `snap_ui::begin` judges it: snapped, a press just outside
|
||||
// can land on the selection's edge (#11392).
|
||||
if let Some(cut) = selection_drag_kind(app, tool, unsnapped(raw), mods) {
|
||||
// ⌘⌥ on a floating piece leaves it where it is and drags a copy of it.
|
||||
if cut
|
||||
&& mods.alt
|
||||
@@ -513,6 +526,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
let tool = crate::retouch_ui::alt_flipped(tool, mods.alt);
|
||||
let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) };
|
||||
drag.capture_vector_owners(app);
|
||||
drag.capture_selection_at_press(app);
|
||||
app.drag = Some(drag);
|
||||
app.trail = None;
|
||||
app.stylus.begin_stroke();
|
||||
@@ -547,6 +561,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
return;
|
||||
}
|
||||
note_left_press(app, raw);
|
||||
tool_move(app, x, y, pressure, mods);
|
||||
feed_live_stroke(app);
|
||||
}
|
||||
@@ -572,9 +587,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
let [x, y] = crate::move_mods::selection_drag_to(&mut d, [x, y], mods.shift && app.active_tool() != Tool::Move);
|
||||
let (click, [x, y]) = selection_release(&d, raw, [x, y]);
|
||||
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
|
||||
// the release point would otherwise nudge the layer onto a nearby edge.
|
||||
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
|
||||
// the release point would otherwise nudge the layer onto a nearby edge. A drag that
|
||||
// snapping held at the press point (no points recorded) and that came back there is
|
||||
// not a click: it keeps a floating piece floating (#11392).
|
||||
if d.tool == Tool::Move && !d.left_press && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
|
||||
app.move_preview = None;
|
||||
crate::move_mods::finish(app);
|
||||
if d.sel_move.is_some() {
|
||||
@@ -586,12 +604,47 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
finish_release(app, d, click);
|
||||
crate::move_mods::finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where pointer event `raw` (before snapping) was.
|
||||
fn unsnapped(raw: ToolEvent) -> [f64; 2] {
|
||||
match raw {
|
||||
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => [x, y],
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` within half a pixel of the press of `d` (a click's reach, a zoomed-in hand's jitter
|
||||
/// included)?
|
||||
fn at_press(d: &Drag, p: [f64; 2]) -> bool {
|
||||
(p[0] - d.start[0]).round() == 0.0 && (p[1] - d.start[1]).round() == 0.0
|
||||
}
|
||||
|
||||
/// Records on the drag that pointer move `raw` (before snapping) left the press (`Drag::left_press`).
|
||||
fn note_left_press(app: &mut PhotocraftApp, raw: ToolEvent) {
|
||||
if let ToolEvent::Move { x, y, .. } = raw
|
||||
&& let Some(d) = app.drag.as_mut()
|
||||
{
|
||||
d.left_press |= !at_press(d, [x, y]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the release `raw` of selection drag `d` is a click, and where it ends (`p`: snapped). A
|
||||
/// click never leaves the press (`at_press`) before snapping, and ends where it was pressed, as
|
||||
/// with snapping off: snapping the release would nudge the selection's edges onto a nearby guide or
|
||||
/// the canvas edge, moving it instead of deselecting (a Single Row / Column Marquee: selecting the
|
||||
/// line). A drag that snapping pulls back onto the press point, or that comes back to it, is not a
|
||||
/// click (#11392).
|
||||
fn selection_release(d: &Drag, raw: ToolEvent, p: [f64; 2]) -> (bool, [f64; 2]) {
|
||||
match raw {
|
||||
ToolEvent::Up { x, y } if d.sel_move.is_some() && !d.left_press && at_press(d, [x, y]) => (true, d.start),
|
||||
_ => (false, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a press with `tool` must not start: it would paint on or move a hidden target layer (the
|
||||
/// engine refuses the command; refusing the press keeps the live stroke from drawing first).
|
||||
fn hidden_target(app: &PhotocraftApp, tool: Tool) -> Option<&'static str> {
|
||||
@@ -623,12 +676,15 @@ fn covers(sel: &photocraft_raster::Surface, p: [f64; 2]) -> bool {
|
||||
/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; a floating piece moves with a plain
|
||||
/// drag; the Polygonal Lasso and Magic Wand need ⌘, as a plain press there places or samples),
|
||||
/// `Some(false)` just the outline (no ⇧ / ⌥ and the options bar on New Selection, so a combining
|
||||
/// drag still draws).
|
||||
/// drag still draws). The Single Row / Single Column Marquee drag the outline too (#11392); a click
|
||||
/// without moving, inside the selection or on a floating piece (dropped first), still selects the
|
||||
/// line under the pointer (`finish_gesture`).
|
||||
pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<bool> {
|
||||
// Click-driven selection tools (a press places a point or samples): only ⌘ drags the
|
||||
// selection, and not while a polygon is being drawn.
|
||||
let clicky = matches!(tool, Tool::PolygonLasso | Tool::MagicWand);
|
||||
if !(clicky || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso)) || (clicky && !app.ui.polygon.is_empty()) {
|
||||
let marquee = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee);
|
||||
if !(clicky || marquee || tool == Tool::Lasso) || (clicky && !app.ui.polygon.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
// ⌘ cuts, ⌘⌥ copies (`select.float`'s `copy`).
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
"memory": photocraft_raster::memory::stats(),
|
||||
"tool": app.ui.tool,
|
||||
"toolOptions": app.ui.tool_options,
|
||||
"artboardTool": app.ui.artboard_tool,
|
||||
"perspectiveCrop": app.ui.perspective_crop,
|
||||
"strokeEditor": app.ui.stroke_editor,
|
||||
"magnetic": app.ui.magnetic,
|
||||
|
||||
@@ -317,6 +317,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields:
|
||||
fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option<bool> {
|
||||
let mut outcome: Option<bool> = None;
|
||||
match d.kind {
|
||||
_ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields),
|
||||
_ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id),
|
||||
_ if crate::color_picker_ui::owns(fields) => {
|
||||
outcome = crate::color_picker_ui::body(ui, fields);
|
||||
@@ -454,7 +455,7 @@ fn button_row(
|
||||
} else {
|
||||
let ok_label = ok_label(d);
|
||||
// Dialogs that need an input first (a preset name, a selection) grey OK out.
|
||||
let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields);
|
||||
let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields) && merge_channels_ui::can_confirm(fields);
|
||||
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
|
||||
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
|
||||
// Dialogs opt in via `dialog_alt_reset::supported` (per marker).
|
||||
@@ -860,6 +861,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[
|
||||
crate::stroke_ui::owns,
|
||||
crate::channel_options_ui::owns,
|
||||
crate::channel_options_ui::owns_new_spot,
|
||||
merge_channels_ui::owns,
|
||||
crate::shape_dialog::owns,
|
||||
crate::delete_layer_prompt::owns,
|
||||
crate::disk_change_prompt::owns,
|
||||
@@ -1011,6 +1013,8 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(),
|
||||
if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) {
|
||||
crate::guide_layout_ui::validate(app, &d.fields)?;
|
||||
}
|
||||
// Merge Channels with too few, repeated, closed or changed documents stays open (#11451).
|
||||
merge_channels_ui::preflight(app, id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1054,6 +1058,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, String>
|
||||
/// The rest of the command dialogs: files, colour, preferences and export, then any other command.
|
||||
fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, String> {
|
||||
match d.kind {
|
||||
_ if merge_channels_ui::owns(&d.fields) => merge_channels_ui::confirm(app, &d.fields),
|
||||
_ if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
|
||||
_ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields),
|
||||
_ if crate::color_picker_ui::owns(&d.fields) => {
|
||||
@@ -1212,6 +1217,7 @@ pub mod layer_picker;
|
||||
pub mod layer_style;
|
||||
pub mod liquify_ui;
|
||||
pub mod mask_trash_prompt;
|
||||
pub mod merge_channels_ui;
|
||||
pub mod new_doc_ui;
|
||||
pub mod new_group_ui;
|
||||
pub mod new_guide_ui;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//!
|
||||
//! On the layer mask (double-click its Channels row, or Mask Options… on its Layers thumbnail)
|
||||
//! it is Photoshop's Layer Mask Display Options: just the rubylith's colour and opacity.
|
||||
//!
|
||||
//! The Channels panel's New Channel… is the same dialog for a channel still to be made (name,
|
||||
//! Masked / Selected Areas, colour, opacity); OK runs `channel.new` (#11451).
|
||||
|
||||
use egui::{Align2, Sense, pos2, vec2};
|
||||
use photocraft_doc::ColorIndicates;
|
||||
@@ -15,6 +18,8 @@ use crate::theme::Tokens;
|
||||
|
||||
pub const COMMAND: &str = "channel.options";
|
||||
const MARK: &str = "__channelOptions";
|
||||
pub const NEW_CHANNEL: &str = "channel.new";
|
||||
const NEW_CHANNEL_MARK: &str = "__newChannel";
|
||||
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
@@ -75,6 +80,28 @@ pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option<u64> {
|
||||
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
|
||||
}
|
||||
|
||||
/// The New Channel fields: Channel Options' for a channel still to be made (the next "Alpha N"
|
||||
/// name, Masked Areas, red at 50%), or `None` without a document. OK runs `channel.new`.
|
||||
pub fn new_channel_fields(app: &PhotocraftApp) -> Option<Map<String, Value>> {
|
||||
let doc = &app.session.active()?.doc;
|
||||
let mut f = Map::new();
|
||||
f.insert(MARK.into(), json!(true));
|
||||
f.insert(NEW_CHANNEL_MARK.into(), json!(true));
|
||||
f.insert("__command".into(), json!(NEW_CHANNEL));
|
||||
f.insert("__label".into(), json!("New Channel…"));
|
||||
f.insert("name".into(), json!(photocraft_engine::channel_cmds::next_alpha_name(doc, "Alpha")));
|
||||
f.insert("indicates".into(), json!("masked"));
|
||||
f.insert("color".into(), json!("#ff0000"));
|
||||
f.insert("opacity".into(), json!(50.0));
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Opens New Channel, or `None` without a document.
|
||||
pub fn open_new_channel(app: &mut PhotocraftApp) -> Option<u64> {
|
||||
let f = new_channel_fields(app)?;
|
||||
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
|
||||
}
|
||||
|
||||
/// The `channel.options` params for the dialog's fields: spot channels take `solidity`, the
|
||||
/// others `opacity`.
|
||||
pub fn params(f: &Map<String, Value>) -> Value {
|
||||
@@ -92,7 +119,7 @@ pub fn params(f: &Map<String, Value>) -> Value {
|
||||
}
|
||||
|
||||
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
app.run(COMMAND, params(f))
|
||||
app.run(if f.contains_key(NEW_CHANNEL_MARK) { NEW_CHANNEL } else { COMMAND }, params(f))
|
||||
}
|
||||
|
||||
fn label(ui: &mut egui::Ui, text: &str) {
|
||||
@@ -122,8 +149,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
ui.indent("channel-options-indicates", |ui| {
|
||||
ui.radio_value(&mut indicates, "masked".to_string(), tl!("Masked Areas"));
|
||||
ui.radio_value(&mut indicates, "selected".to_string(), tl!("Selected Areas"));
|
||||
// Quick Mask can't be a spot channel.
|
||||
if !quick {
|
||||
// Quick Mask can't be a spot channel; New Spot Channel… makes spot channels.
|
||||
if !quick && !f.contains_key(NEW_CHANNEL_MARK) {
|
||||
ui.radio_value(&mut indicates, "spot".to_string(), tl!("Spot Color"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The History Options dialog (History panel menu › History Options…, #2966): a form over the
|
||||
//! engine's `history.options` with its two checkboxes, filled with the current values. OK runs
|
||||
//! the command with the dialog's values (the generic command-dialog confirm), Cancel changes
|
||||
//! nothing. Agents can also run `history.options` with params directly, or `prefs.set
|
||||
//! historyOptions.*`; a call with params skips the dialog.
|
||||
//! engine's `history.options` with Photoshop's five checkboxes in its order, filled with the
|
||||
//! current values. OK runs the command with the dialog's values (the generic command-dialog
|
||||
//! confirm), Cancel changes nothing. Agents can also run `history.options` with params directly,
|
||||
//! or `prefs.set historyOptions.*`; a call with params skips the dialog.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -27,11 +27,16 @@ pub fn open(app: &mut PhotocraftApp) -> u64 {
|
||||
f.insert("__label".into(), json!("History Options…"));
|
||||
f.insert("__form".into(), json!(true));
|
||||
f.insert("__choices".into(), json!({}));
|
||||
// Photoshop's capitalised labels, in the UI language (the form would sentence-case the keys).
|
||||
// Photoshop's order and capitalised labels, in the UI language (the form would sort the keys
|
||||
// and sentence-case them).
|
||||
f.insert("__order".into(), json!(photocraft_engine::history_options_cmds::OPTIONS));
|
||||
f.insert(
|
||||
"__labels".into(),
|
||||
json!({
|
||||
"automaticallyCreateFirstSnapshot": tl!("Automatically Create First Snapshot"),
|
||||
"automaticallyCreateNewSnapshotWhenSaving": tl!("Automatically Create New Snapshot When Saving"),
|
||||
"allowNonLinearHistory": tl!("Allow Non-Linear History"),
|
||||
"showNewSnapshotDialogByDefault": tl!("Show New Snapshot Dialog by Default"),
|
||||
"makeLayerVisibilityChangesUndoable": tl!("Make Layer Visibility Changes Undoable"),
|
||||
}),
|
||||
);
|
||||
@@ -58,19 +63,25 @@ mod tests {
|
||||
let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap();
|
||||
assert_eq!(d.fields["allowNonLinearHistory"], json!(false));
|
||||
assert_eq!(d.fields["makeLayerVisibilityChangesUndoable"], json!(true));
|
||||
assert_eq!(d.fields["automaticallyCreateFirstSnapshot"], json!(true));
|
||||
assert_eq!(d.fields["automaticallyCreateNewSnapshotWhenSaving"], json!(false));
|
||||
assert_eq!(d.fields["showNewSnapshotDialogByDefault"], json!(false));
|
||||
assert_eq!(crate::dialogs::title(d), "History Options");
|
||||
// Cancel changes nothing.
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("allowNonLinearHistory".into(), json!(true));
|
||||
crate::dialogs::cancel(&mut app, id).unwrap();
|
||||
assert!(!app.session.prefs().history_options.allow_non_linear_history);
|
||||
// OK applies both checkboxes.
|
||||
// OK applies every checkbox.
|
||||
let id = open(&mut app);
|
||||
let fields = &mut app.ui.dialog_mut(id).unwrap().fields;
|
||||
fields.insert("allowNonLinearHistory".into(), json!(true));
|
||||
fields.insert("makeLayerVisibilityChangesUndoable".into(), json!(false));
|
||||
for key in photocraft_engine::history_options_cmds::OPTIONS {
|
||||
let on = fields[key].as_bool().unwrap();
|
||||
fields.insert(key.into(), json!(!on));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
let o = &app.session.prefs().history_options;
|
||||
assert!(o.allow_non_linear_history && !o.make_layer_visibility_changes_undoable);
|
||||
assert!(!o.automatically_create_first_snapshot && o.automatically_create_new_snapshot_when_saving && o.show_new_snapshot_dialog_by_default);
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
// Reopened, it shows what was chosen; with params the command runs without a dialog.
|
||||
let id = open(&mut app);
|
||||
@@ -82,16 +93,24 @@ mod tests {
|
||||
assert!(crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"allowNonLinearHistory": "no"})).is_err());
|
||||
}
|
||||
|
||||
/// The five checkboxes with Photoshop's labels, top to bottom in its dialog's order.
|
||||
#[test]
|
||||
fn the_dialog_draws_both_checkboxes_with_photoshops_labels() {
|
||||
fn the_dialog_draws_the_checkboxes_in_photoshops_order() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut h = Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
open(h.state_mut());
|
||||
h.run_steps(3);
|
||||
h.get_by_label("History Options");
|
||||
h.get_by_label("Allow Non-Linear History");
|
||||
h.get_by_label("Make Layer Visibility Changes Undoable");
|
||||
let labels = [
|
||||
"Automatically Create First Snapshot",
|
||||
"Automatically Create New Snapshot When Saving",
|
||||
"Allow Non-Linear History",
|
||||
"Show New Snapshot Dialog by Default",
|
||||
"Make Layer Visibility Changes Undoable",
|
||||
];
|
||||
let tops: Vec<f32> = labels.iter().map(|l| h.get_by_label(l).rect().min.y).collect();
|
||||
assert!(tops.windows(2).all(|w| w[0] < w[1]), "top to bottom: {tops:?}");
|
||||
h.get_by_label("OK");
|
||||
h.get_by_label("Cancel");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Channels panel › Merge Channels…: the mode (RGB, CMYK or Lab Color) and, for each of its
|
||||
//! channels, the open grayscale document that becomes it. Photoshop asks in two steps (the mode,
|
||||
//! then the documents); here both are one dialog. Only the modes the open documents can fill are
|
||||
//! offered (RGB and Lab need three, CMYK four), OK waits for a different document per channel,
|
||||
//! and a merge that still fails opens the dialog again with the reason. OK runs the engine's
|
||||
//! `channel.merge`, which closes the sources; agents call it directly with params (#11451).
|
||||
//!
|
||||
//! The dialog names documents by their [`photocraft_doc::DocId`], not their tab index: work that
|
||||
//! runs while it is open (a script moving or closing a document) can shift the indices, and OK
|
||||
//! must merge, and close, the documents that were picked. They are looked up again at OK.
|
||||
|
||||
use photocraft_doc::ColorMode;
|
||||
use photocraft_engine::channel_cmds;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub const COMMAND: &str = "channel.merge";
|
||||
const MARK: &str = "__mergeChannels";
|
||||
/// The most channels a mode has (CMYK).
|
||||
const SLOTS: usize = 4;
|
||||
const MODES: [(&str, &str); 3] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color"), ("lab", "Lab Color")];
|
||||
const TOO_FEW: &str = "Not enough grayscale documents for this mode.";
|
||||
const REPEATED: &str = "Pick a different document for each channel.";
|
||||
const CLOSED: &str = "A picked document is no longer open.";
|
||||
const CHANGED: &str = "A picked document is no longer a grayscale document of the same size.";
|
||||
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
}
|
||||
|
||||
fn mode_of(key: &str) -> ColorMode {
|
||||
match key {
|
||||
"cmyk" => ColorMode::Cmyk,
|
||||
"lab" => ColorMode::Lab,
|
||||
_ => ColorMode::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes `count` documents can fill, in the menu's order.
|
||||
fn modes(count: usize) -> Vec<(String, &'static str)> {
|
||||
MODES.iter().filter(|(key, _)| mode_of(key).color_channels() <= count).map(|(key, label)| (key.to_string(), *label)).collect()
|
||||
}
|
||||
|
||||
/// The documents that can be merged, by document id and name: the open grayscale ones of the
|
||||
/// active document's size, the active one first, as the engine picks them without params.
|
||||
fn sources(app: &PhotocraftApp) -> Vec<(u64, String)> {
|
||||
let Some(active) = app.session.active() else { return Vec::new() };
|
||||
let docs = app.session.documents();
|
||||
let fits = |i: usize| docs.get(i).filter(|d| d.doc.mode == ColorMode::Grayscale && d.doc.size == active.doc.size);
|
||||
let current = app.session.active_index();
|
||||
let others = (0..docs.len()).filter(|i| Some(*i) != current);
|
||||
current.into_iter().chain(others).filter_map(|i| fits(i).map(|d| (d.doc.id.0, d.doc.name.clone()))).collect()
|
||||
}
|
||||
|
||||
fn mode_field(f: &Map<String, Value>) -> &str {
|
||||
f.get("mode").and_then(Value::as_str).unwrap_or("rgb")
|
||||
}
|
||||
|
||||
fn sources_field(f: &Map<String, Value>) -> Vec<(u64, String)> {
|
||||
f.get("__sources").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The picked document ids, one per channel of the mode.
|
||||
fn picks(f: &Map<String, Value>) -> Vec<u64> {
|
||||
let all: Vec<u64> = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
all.into_iter().take(mode_of(mode_field(f)).color_channels()).collect()
|
||||
}
|
||||
|
||||
/// The dialog's fields: the first mode the documents can fill (RGB Color from three) and the
|
||||
/// merge-able documents in order, one per channel, or `None` without a document.
|
||||
pub fn fields(app: &PhotocraftApp) -> Option<Map<String, Value>> {
|
||||
app.session.active()?;
|
||||
let sources = sources(app);
|
||||
let picks: Vec<u64> = sources.iter().take(SLOTS).map(|(id, _)| *id).collect();
|
||||
let mode = modes(sources.len()).first().map_or_else(|| "rgb".to_string(), |(key, _)| key.clone());
|
||||
let mut f = Map::new();
|
||||
f.insert(MARK.into(), json!(true));
|
||||
f.insert("__command".into(), json!(COMMAND));
|
||||
f.insert("__label".into(), json!("Merge Channels…"));
|
||||
f.insert("__sources".into(), json!(sources));
|
||||
f.insert("mode".into(), json!(mode));
|
||||
f.insert("documents".into(), json!(picks));
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Opens Merge Channels, or `None` without a document.
|
||||
pub fn open(app: &mut PhotocraftApp) -> Option<u64> {
|
||||
let f = fields(app)?;
|
||||
Some(app.ui.open_dialog(crate::state::DialogKind::Command, f))
|
||||
}
|
||||
|
||||
/// Why OK is greyed (English, for `tl!`): the mode needs more documents than are offered, or the
|
||||
/// picks repeat a document or name one the dialog did not offer. `None` when OK can merge, and
|
||||
/// for another dialog's fields.
|
||||
pub fn problem(f: &Map<String, Value>) -> Option<&'static str> {
|
||||
if !owns(f) {
|
||||
return None;
|
||||
}
|
||||
let n = mode_of(mode_field(f)).color_channels();
|
||||
let sources = sources_field(f);
|
||||
let picks = picks(f);
|
||||
if sources.len() < n || picks.len() < n {
|
||||
return Some(TOO_FEW);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let offered = |p: &u64| sources.iter().any(|(id, _)| id == p);
|
||||
if picks.iter().any(|p| !seen.insert(*p) || !offered(p)) {
|
||||
return Some(REPEATED);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether OK is live: always for other dialogs, here while [`problem`] finds none.
|
||||
pub fn can_confirm(f: &Map<String, Value>) -> bool {
|
||||
problem(f).is_none()
|
||||
}
|
||||
|
||||
/// The picked documents' current tab indices, looked up by id; refused (English, for `tl!`)
|
||||
/// when one was closed, or is no longer a grayscale document of the others' size.
|
||||
fn resolve(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Vec<usize>, &'static str> {
|
||||
if let Some(why) = problem(f) {
|
||||
return Err(why);
|
||||
}
|
||||
let docs = app.session.documents();
|
||||
let indices = picks(f).iter().map(|id| docs.iter().position(|d| d.doc.id.0 == *id).ok_or(CLOSED)).collect::<Result<Vec<_>, _>>()?;
|
||||
let first = indices.first().and_then(|&i| docs.get(i)).map(|d| d.doc.size);
|
||||
let fits = |i: &usize| docs.get(*i).is_some_and(|d| d.doc.mode == ColorMode::Grayscale && Some(d.doc.size) == first);
|
||||
if indices.iter().all(fits) { Ok(indices) } else { Err(CHANGED) }
|
||||
}
|
||||
|
||||
/// Checked before the dialog closes (`dialogs::confirm_preflight`), so a refused OK keeps it
|
||||
/// open with the reason under the pickers: too few or repeated documents, or a picked one that
|
||||
/// was closed or changed while the dialog was open.
|
||||
pub fn preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> {
|
||||
let Some(f) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| &d.fields).filter(|f| owns(f)) else { return Ok(()) };
|
||||
let Err(why) = resolve(app, f) else { return Ok(()) };
|
||||
if let Some(d) = app.ui.dialog_mut(id) {
|
||||
d.fields.insert("__error".into(), json!(why));
|
||||
}
|
||||
Err(why.to_string())
|
||||
}
|
||||
|
||||
/// The `channel.merge` params for the dialog's fields: the mode and the picked documents' current
|
||||
/// indices, one per channel.
|
||||
pub fn params(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let documents = resolve(app, f).map_err(str::to_string)?;
|
||||
Ok(json!({ "mode": mode_field(f), "documents": documents }))
|
||||
}
|
||||
|
||||
/// Runs the merge. Should it still be refused, the dialog opens again with its picks and the
|
||||
/// reason, rather than going away.
|
||||
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let result = params(app, f).and_then(|p| app.run(COMMAND, p));
|
||||
if let Err(e) = &result {
|
||||
let mut again = f.clone();
|
||||
again.insert("__error".into(), json!(e));
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, again);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let sources = sources_field(f);
|
||||
let mut mode = mode_field(f).to_string();
|
||||
let modes = modes(sources.len());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Mode:"));
|
||||
if crate::widgets::dropdown(ui, "merge-channels-mode", tl!("Mode:"), &mut mode, &modes, 160.0) {
|
||||
f.insert("mode".into(), json!(mode));
|
||||
f.remove("__error");
|
||||
}
|
||||
});
|
||||
crate::widgets::section_label(ui, tl!("Specify Channels:"));
|
||||
crate::widgets::hairline(ui);
|
||||
let options: Vec<(u64, &str)> = sources.iter().map(|(id, name)| (*id, name.as_str())).collect();
|
||||
let mut picks: Vec<u64> = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let mut changed = false;
|
||||
for (k, channel) in channel_cmds::color_names(mode_of(&mode)).iter().enumerate() {
|
||||
let Some(pick) = picks.get_mut(k) else { break };
|
||||
ui.horizontal(|ui| {
|
||||
let caption = format!("{}:", tl!(channel));
|
||||
ui.add_sized([84.0, 22.0], egui::Label::new(&caption));
|
||||
changed |= crate::widgets::dropdown_literal(ui, &format!("merge-channels-{k}"), &caption, pick, &options, 200.0);
|
||||
});
|
||||
}
|
||||
if changed {
|
||||
f.insert("documents".into(), json!(picks));
|
||||
f.remove("__error");
|
||||
}
|
||||
// Why OK is greyed, or why the last OK was refused (the dialog's reasons are catalogued; the
|
||||
// engine's own are shown as they come).
|
||||
let why: Option<&str> = problem(f);
|
||||
let why = why.or_else(|| f.get("__error").and_then(Value::as_str)).map(|w| tl!(w).to_string());
|
||||
if let Some(why) = why {
|
||||
ui.label(egui::RichText::new(why).color(Tokens::get(ui.ctx()).danger).size(11.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app(docs: usize) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
for _ in 0..docs {
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
fn id(app: &PhotocraftApp, index: usize) -> u64 {
|
||||
app.session.documents()[index].doc.id.0
|
||||
}
|
||||
|
||||
fn offered(f: &Map<String, Value>) -> Vec<String> {
|
||||
modes(sources_field(f).len()).into_iter().map(|(key, _)| key).collect()
|
||||
}
|
||||
|
||||
/// The merged document's first pixel as 8-bit RGB.
|
||||
fn merged_pixel(app: &PhotocraftApp) -> [u8; 3] {
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let px = photocraft_compose::render(doc, doc.bounds()).px[0];
|
||||
[px[0], px[1], px[2]].map(|v| (v * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
/// Three grayscale documents filled black, mid gray and white, then the active one is `active`.
|
||||
fn shaded(active: usize) -> PhotocraftApp {
|
||||
let mut app = app(3);
|
||||
for (i, gray) in ["#000000", "#808080", "#ffffff"].into_iter().enumerate() {
|
||||
app.session.set_active(i);
|
||||
app.run("edit.fill", json!({"color": gray})).unwrap();
|
||||
}
|
||||
app.session.set_active(active);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_list_the_sources_by_id_and_params_take_their_indices() {
|
||||
let mut app = app(3);
|
||||
app.run("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "mode": "rgb"})).unwrap();
|
||||
app.session.set_active(2);
|
||||
let f = fields(&app).unwrap();
|
||||
let ids = [id(&app, 2), id(&app, 0), id(&app, 1)];
|
||||
assert_eq!(f["__sources"].as_array().unwrap().iter().map(|s| s[0].as_u64().unwrap()).collect::<Vec<_>>(), ids, "the active one first");
|
||||
assert_eq!((f["mode"].clone(), f["documents"].clone()), (json!("rgb"), json!(ids)), "one each, none repeated");
|
||||
assert_eq!(params(&app, &f).unwrap(), json!({"mode": "rgb", "documents": [2, 0, 1]}));
|
||||
assert!(fields(&PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())).is_none());
|
||||
}
|
||||
|
||||
/// Three documents fill RGB and Lab, not CMYK; four fill CMYK too.
|
||||
#[test]
|
||||
fn only_the_modes_the_documents_fill_are_offered() {
|
||||
let f = fields(&app(3)).unwrap();
|
||||
assert_eq!(offered(&f), ["rgb", "lab"]);
|
||||
assert!(can_confirm(&f));
|
||||
let mut cmyk = f.clone();
|
||||
cmyk.insert("mode".into(), json!("cmyk"));
|
||||
assert_eq!(problem(&cmyk), Some(TOO_FEW), "CMYK from three documents (as automation could ask) greys OK");
|
||||
let app = app(4);
|
||||
let f = fields(&app).unwrap();
|
||||
assert_eq!(offered(&f), ["rgb", "cmyk", "lab"]);
|
||||
let mut cmyk = f.clone();
|
||||
cmyk.insert("mode".into(), json!("cmyk"));
|
||||
assert!(can_confirm(&cmyk));
|
||||
assert_eq!(params(&app, &cmyk).unwrap()["documents"], json!([3, 0, 1, 2]), "the active (last opened) one first");
|
||||
}
|
||||
|
||||
/// A document picked for two channels greys OK with the reason; a refused OK keeps the dialog
|
||||
/// open and merges nothing.
|
||||
#[test]
|
||||
fn repeated_picks_grey_ok_and_keep_the_dialog_open() {
|
||||
let mut app = app(3);
|
||||
let (a, b) = (id(&app, 0), id(&app, 1));
|
||||
let id = open(&mut app).unwrap();
|
||||
let d = app.ui.dialog_mut(id).unwrap();
|
||||
d.fields.insert("documents".into(), json!([a, a, b]));
|
||||
assert_eq!(problem(&d.fields), Some(REPEATED));
|
||||
assert!(!can_confirm(&d.fields));
|
||||
d.fields.insert("documents".into(), json!([a, b, 999_999]));
|
||||
assert_eq!(problem(&d.fields), Some(REPEATED), "a document the dialog did not offer");
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert!(app.ui.dialogs.iter().any(|d| d.id == id), "the dialog stays open");
|
||||
assert_eq!(app.session.documents().len(), 3, "nothing merged or closed");
|
||||
assert!(can_confirm(&Map::new()), "other dialogs are not affected");
|
||||
}
|
||||
|
||||
/// Documents moved while the dialog is open: OK still merges the ones picked, by id, into the
|
||||
/// channels picked for them.
|
||||
#[test]
|
||||
fn ok_merges_the_picked_documents_after_a_reorder() {
|
||||
let mut app = shaded(0);
|
||||
let (black, gray, white) = (id(&app, 0), id(&app, 1), id(&app, 2));
|
||||
let dialog = open(&mut app).unwrap();
|
||||
app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!([black, white, gray]));
|
||||
// A script reorders the tabs while the dialog is open: white first, black last.
|
||||
app.session.execute("document.move", json!({"document": 2, "to": 0})).unwrap();
|
||||
app.session.execute("document.move", json!({"document": 1, "to": 2})).unwrap();
|
||||
assert_eq!([id(&app, 0), id(&app, 1), id(&app, 2)], [white, gray, black]);
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert_eq!((app.session.documents().len(), app.session.active().unwrap().doc.mode), (1, ColorMode::Rgb));
|
||||
assert_eq!(merged_pixel(&app), [0, 255, 128], "red, green and blue from the documents picked, not their old tabs");
|
||||
}
|
||||
|
||||
/// A picked document closed while the dialog is open: OK refuses with the reason, keeps the
|
||||
/// dialog and closes nothing, even when another same-size grayscale document took its tab.
|
||||
#[test]
|
||||
fn a_closed_pick_is_refused_and_no_other_document_merges() {
|
||||
let mut app = shaded(0);
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
let spare = id(&app, 3);
|
||||
let dialog = open(&mut app).unwrap();
|
||||
let picked: Vec<u64> = (0..3).map(|i| id(&app, i)).collect();
|
||||
app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!(picked));
|
||||
app.session.execute("file.close", json!({"document": 1})).unwrap();
|
||||
assert_eq!(id(&app, 2), spare, "the spare document is where a picked one was");
|
||||
let why = crate::dialogs::confirm(&mut app, dialog).unwrap_err();
|
||||
assert_eq!(why, CLOSED);
|
||||
let d = app.ui.dialogs.iter().find(|d| d.id == dialog).expect("the dialog stays open");
|
||||
assert_eq!(d.fields["__error"], json!(CLOSED), "with the reason under the pickers");
|
||||
assert_eq!(app.session.documents().len(), 3, "nothing merged or closed");
|
||||
assert!(app.session.documents().iter().any(|d| d.doc.id.0 == spare));
|
||||
}
|
||||
|
||||
/// A picked document turned into another mode while the dialog is open is refused too; should
|
||||
/// the engine refuse a merge after OK, the dialog comes back with the reason.
|
||||
#[test]
|
||||
fn a_changed_pick_or_a_refused_merge_keeps_the_dialog() {
|
||||
let mut app = app(3);
|
||||
let dialog = open(&mut app).unwrap();
|
||||
let f = app.ui.dialog_mut(dialog).unwrap().fields.clone();
|
||||
app.run("image.mode.rgb", json!({})).unwrap();
|
||||
assert_eq!(crate::dialogs::confirm(&mut app, dialog).unwrap_err(), CHANGED);
|
||||
assert!(app.ui.dialogs.iter().any(|d| d.id == dialog));
|
||||
app.ui.dialogs.clear();
|
||||
assert!(confirm(&mut app, &f).is_err());
|
||||
let again = &app.ui.dialogs.last().expect("the dialog again").fields;
|
||||
assert!(owns(again) && again["__error"].is_string());
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_renders_every_mode_and_the_reason() {
|
||||
let app = app(4);
|
||||
let ids: Vec<u64> = (0..4).map(|i| id(&app, i)).collect();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
for (mode, docs, error) in
|
||||
[("rgb", json!(ids[..3]), None), ("cmyk", json!([ids[0], ids[1], ids[2], ids[2]]), None), ("lab", json!(ids[1..]), Some(CLOSED))]
|
||||
{
|
||||
let mut f = fields(&app).unwrap();
|
||||
f.insert("mode".into(), json!(mode));
|
||||
f.insert("documents".into(), docs);
|
||||
if let Some(e) = error {
|
||||
f.insert("__error".into(), json!(e));
|
||||
}
|
||||
ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear();
|
||||
assert!(owns(&f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1408,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
w.pick(t);
|
||||
}
|
||||
}
|
||||
// E cycles the tool mode (add, subtract); Hand and Zoom edit nothing, so it leaves them alone.
|
||||
let bare_e = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::E, pressed: true, modifiers, .. } if modifiers.is_none())));
|
||||
if bare_e && w.tool.edits() && pressed(Key::E, Modifiers::NONE) {
|
||||
w.add = !w.add;
|
||||
}
|
||||
if pressed(Key::OpenBracket, Modifiers::NONE) {
|
||||
let s = w.size();
|
||||
w.set_size((s * 0.9).min(s - 1.0));
|
||||
@@ -1973,5 +1978,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycle_tool_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use super::*;
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
fn opened() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap();
|
||||
app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
app.session.prefs.edit(|p| {
|
||||
p.dialogs.remove(REMEMBERED);
|
||||
});
|
||||
app.sync_views();
|
||||
open(&mut app).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace {
|
||||
app.distort.select_mask.as_ref().unwrap()
|
||||
}
|
||||
|
||||
fn ui_selected(app: &mut PhotocraftApp) -> u64 {
|
||||
control(app, &json!({"waitPreview": true})).unwrap()["selectedProxyPixels"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) {
|
||||
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
let input = egui::RawInput { events: vec![ev], ..Default::default() };
|
||||
ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_switches_between_add_and_subtract_and_back() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(ws(&app).add);
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(!ws(&app).add);
|
||||
assert_eq!(ws(&app).describe()["mode"].as_str(), Some("subtract"));
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(ws(&app).add);
|
||||
assert_eq!(ws(&app).describe()["mode"].as_str(), Some("add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_works_with_every_editing_tool_and_keeps_the_tool() {
|
||||
let ctx = egui::Context::default();
|
||||
for tool in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, SmTool::Lasso, SmTool::PolygonalLasso] {
|
||||
let mut app = opened();
|
||||
app.distort.select_mask.as_mut().unwrap().pick(tool);
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(!ws(&app).add, "{tool:?}");
|
||||
assert_eq!(ws(&app).tool, tool);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_does_nothing_with_hand_or_zoom() {
|
||||
let ctx = egui::Context::default();
|
||||
for tool in [SmTool::Hand, SmTool::Zoom] {
|
||||
let mut app = opened();
|
||||
app.distort.select_mask.as_mut().unwrap().pick(tool);
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(ws(&app).add, "{tool:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_with_command_alt_or_shift_does_nothing_new() {
|
||||
let ctx = egui::Context::default();
|
||||
for m in [Modifiers::COMMAND, Modifiers::ALT, Modifiers::SHIFT, Modifiers::CTRL] {
|
||||
let mut app = opened();
|
||||
press(&mut app, &ctx, Key::E, m);
|
||||
assert!(ws(&app).add, "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_is_ignored_while_a_popup_has_the_keys() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
egui::Popup::open_id(ui.ctx(), egui::Id::unique("sm-test-popup"));
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(egui::Popup::is_any_open(&ctx));
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(ws(&app).add);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_chooses_the_mode_alt_swaps_while_held() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(!ws(&app).add);
|
||||
// Alt held on top of subtract: the stroke adds, so the pixel under it becomes selected.
|
||||
let before = ui_selected(&mut app);
|
||||
let stroke = json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}});
|
||||
control(&mut app, &stroke).unwrap();
|
||||
assert!(!ws(&app).add, "Alt does not change the chosen mode");
|
||||
assert!(ui_selected(&mut app) > before, "Alt over subtract adds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polygon_in_progress_keeps_its_starting_mode() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
app.distort.select_mask.as_mut().unwrap().pick(SmTool::PolygonalLasso);
|
||||
app.distort.select_mask.as_mut().unwrap().poly = Some((true, vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]]));
|
||||
press(&mut app, &ctx, Key::E, Modifiers::NONE);
|
||||
assert!(!ws(&app).add);
|
||||
assert_eq!(ws(&app).poly.as_ref().map(|(a, _)| *a), Some(true));
|
||||
assert!(enter(&mut app), "Enter closes the polygon");
|
||||
let edit = ws(&app).edits.last().expect("the closed polygon is recorded");
|
||||
assert_eq!(edit["tool"].as_str(), Some("lasso"));
|
||||
assert_eq!(edit["mode"].as_str(), Some("add"), "the mode the polygon started with");
|
||||
assert!(!ws(&app).add, "the workspace mode is still the one E chose");
|
||||
}
|
||||
|
||||
fn e_event() -> egui::RawInput {
|
||||
let ev = egui::Event::Key { key: Key::E, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
egui::RawInput { events: vec![ev], ..Default::default() }
|
||||
}
|
||||
|
||||
/// Through the shortcut handler, as the app runs it: a focused text field keeps the E.
|
||||
#[test]
|
||||
fn e_through_handle_is_ignored_while_a_text_field_has_focus() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
let mut text = String::new();
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
ui.text_edit_singleline(&mut text).request_focus();
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
ctx.run_ui(e_event(), |ui| {
|
||||
assert!(ui.ctx().text_edit_focused());
|
||||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||||
ui.text_edit_singleline(&mut text);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(ws(&app).add);
|
||||
}
|
||||
|
||||
/// The same path with no text field focused: E flips the mode.
|
||||
#[test]
|
||||
fn e_through_handle_flips_the_mode_without_a_focused_text_field() {
|
||||
let mut app = opened();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(e_event(), |ui| {
|
||||
assert!(!ui.ctx().text_edit_focused());
|
||||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(!ws(&app).add);
|
||||
}
|
||||
@@ -50,6 +50,30 @@ fn dialog_maps_to_engine_command() {
|
||||
assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50));
|
||||
}
|
||||
|
||||
/// #9260: OK with a new Width resizes a document whose own resolution is outside the range
|
||||
/// the command accepts (an imported PNG's pHYs of 1 px/m is 0.0254 ppi); the dialog sends that
|
||||
/// resolution back unchanged.
|
||||
#[test]
|
||||
fn ok_resizes_a_document_with_an_imported_resolution_out_of_range() {
|
||||
for own in [0.0254_f32, 40_000.0] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap();
|
||||
app.session
|
||||
.edit("ppi", |doc, _| {
|
||||
doc.resolution_dpi = own;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let id = open(&mut app, "image.imageSize").unwrap();
|
||||
let d = app.ui.dialog_mut(id).unwrap();
|
||||
d.fields.insert("width".into(), json!(100));
|
||||
d.fields.insert("height".into(), json!(50));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), own), "{own}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_reset_restores_open_dimensions() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! #11403: the document tab's right-click menu lists the reference application's eight entries in
|
||||
//! order, with its two separators, and each entry runs the command it names on the clicked tab.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::canvas::tabs::{TAB_MENU_SEPARATORS_AFTER, tab_context_items, tab_context_menu};
|
||||
use crate::state::DialogKind;
|
||||
use crate::{FileDialogRequest, PhotocraftApp, Services};
|
||||
|
||||
/// The app, what the menu is drawn for, and the command the last click returned.
|
||||
struct Probe {
|
||||
app: PhotocraftApp,
|
||||
index: usize,
|
||||
has_path: bool,
|
||||
picked: Option<(&'static str, Value)>,
|
||||
}
|
||||
|
||||
type Requests = Rc<RefCell<Vec<FileDialogRequest>>>;
|
||||
|
||||
fn probe(documents: usize, index: usize, has_path: bool) -> (Harness<'static, Probe>, Requests) {
|
||||
let requests: Requests = Default::default();
|
||||
let held = requests.clone();
|
||||
let services = Services { file_dialog: Some(Box::new(move |request, _parent, _reply| held.borrow_mut().push(request))), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
for n in 0..documents {
|
||||
app.run("file.new", json!({"width": 10 + n, "height": 8})).unwrap();
|
||||
}
|
||||
let state = Probe { app, index, has_path, picked: None };
|
||||
let mut h = Harness::builder().with_size(vec2(400.0, 500.0)).build_ui_state(
|
||||
|ui, p: &mut Probe| {
|
||||
let count = p.app.session.documents().len();
|
||||
if let Some(hit) = tab_context_menu(ui, p.index, count, p.has_path) {
|
||||
p.picked = Some(hit);
|
||||
}
|
||||
},
|
||||
state,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(3);
|
||||
(h, requests)
|
||||
}
|
||||
|
||||
/// The drawn rows top to bottom: label, top edge, enabled.
|
||||
fn rows(h: &Harness<'static, Probe>) -> Vec<(String, f32, bool)> {
|
||||
let mut rows: Vec<(String, f32, bool)> = h
|
||||
.query_all_by_role(egui::accesskit::Role::Button)
|
||||
.map(|n| {
|
||||
let a = n.accesskit_node();
|
||||
(a.label().unwrap_or_default().to_string(), n.rect().top(), !a.is_disabled())
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
rows
|
||||
}
|
||||
|
||||
/// Each document's width, which tells the documents apart (`probe` makes them 10, 11, 12 wide).
|
||||
fn widths(h: &Harness<'static, Probe>) -> Vec<u32> {
|
||||
h.state().app.session.documents().iter().map(|d| d.doc.size.width).collect()
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'static, Probe>, label: &str) -> (&'static str, Value) {
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().picked.take().unwrap_or_else(|| panic!("clicking {label} returned no command"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_lists_the_eight_reference_entries_in_order() {
|
||||
let (h, _) = probe(1, 0, false);
|
||||
let labels: Vec<String> = rows(&h).into_iter().map(|r| r.0).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"Consolidate All to Here",
|
||||
"Close",
|
||||
"Close All",
|
||||
"Close Others",
|
||||
"Move to New Window",
|
||||
"New Document…",
|
||||
"Open Document…",
|
||||
crate::menus::reveal_label(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separators_follow_move_to_new_window_and_open_document() {
|
||||
assert_eq!(TAB_MENU_SEPARATORS_AFTER, [4, 6]);
|
||||
let (h, _) = probe(1, 0, false);
|
||||
let tops: Vec<f32> = rows(&h).into_iter().map(|r| r.1).collect();
|
||||
let gaps: Vec<f32> = tops.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let plain = gaps[0];
|
||||
for (i, gap) in gaps.iter().enumerate() {
|
||||
let separated = TAB_MENU_SEPARATORS_AFTER.contains(&i);
|
||||
assert_eq!(*gap > plain + 1.0, separated, "gap after row {i} is {gap} against a plain {plain}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_row_names_its_command_and_the_clicked_tabs_index() {
|
||||
let items = tab_context_items(2, 3, true);
|
||||
let menu = crate::menus::menu_items(&PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()));
|
||||
let want = [
|
||||
("Consolidate All to Here", "window.arrange.consolidateAllToTabs", json!({})),
|
||||
("Close", "file.close", json!({"document": 2})),
|
||||
("Close All", "file.closeAll", json!({})),
|
||||
("Close Others", "file.closeOthers", json!({"document": 2})),
|
||||
("Move to New Window", "window.arrange.floatInWindow", json!({"document": 2})),
|
||||
("New Document…", "file.new", json!({})),
|
||||
("Open Document…", "file.open", json!({})),
|
||||
(crate::menus::reveal_label(), "file.revealInFinder", json!({"document": 2})),
|
||||
];
|
||||
for ((label, id, params, _), (wl, wi, wp)) in items.iter().zip(want) {
|
||||
assert_eq!((*label, *id, params), (wl, wi, &wp));
|
||||
assert!(menu.iter().any(|i| i.id == *id) || photocraft_engine::commands::find(id).is_some(), "{id} is a command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_states_for_one_saved_one_unsaved_and_two_documents() {
|
||||
let enabled = |documents: usize, has_path: bool| -> Vec<bool> {
|
||||
let (h, _) = probe(documents, 0, has_path);
|
||||
rows(&h).into_iter().map(|r| r.2).collect()
|
||||
};
|
||||
// Consolidate, Close, Close All, Close Others, Move, New, Open, Reveal.
|
||||
assert_eq!(enabled(1, true), [false, true, true, false, true, true, true, true], "one saved document");
|
||||
assert_eq!(enabled(1, false), [false, true, true, false, true, true, true, false], "one unsaved document");
|
||||
assert_eq!(enabled(2, true), [true, true, true, true, true, true, true, true], "two documents");
|
||||
assert_eq!(enabled(2, false), [true, true, true, true, true, true, true, false], "two documents, the clicked one unsaved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_close_all_and_close_others_act_on_the_clicked_tab() {
|
||||
let (mut h, _) = probe(3, 1, true);
|
||||
let (id, params) = click(&mut h, "Close");
|
||||
assert_eq!((id, ¶ms), ("file.close", &json!({"document": 1})));
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
let names = widths(&h);
|
||||
assert_eq!(names, [10, 12]);
|
||||
|
||||
let (mut h, _) = probe(3, 1, true);
|
||||
let (id, params) = click(&mut h, "Close Others");
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
let names = widths(&h);
|
||||
assert_eq!(names, [11]);
|
||||
|
||||
let (mut h, _) = probe(3, 1, true);
|
||||
let (id, params) = click(&mut h, "Close All");
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
assert!(h.state().app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_to_new_window_floats_the_clicked_tab_even_with_one_document() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut h, _) = probe(1, 0, false);
|
||||
let (id, params) = click(&mut h, "Move to New Window");
|
||||
assert_eq!((id, ¶ms), ("window.arrange.floatInWindow", &json!({"document": 0})));
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
assert!(h.state().app.ui.windows.iter().any(|w| w.floating && w.document == 0));
|
||||
|
||||
// The clicked tab (2) is not the active document (0).
|
||||
let (mut h, _) = probe(3, 2, true);
|
||||
h.state_mut().app.session.set_active(0);
|
||||
assert_eq!(h.state().app.session.active_index(), Some(0));
|
||||
let (id, params) = click(&mut h, "Move to New Window");
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
let windows = &h.state().app.ui.windows;
|
||||
assert!(windows.iter().any(|w| w.floating && w.document == 2), "the clicked tab floats");
|
||||
assert!(!windows.iter().any(|w| w.document == 0), "the active document does not float");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_all_to_here_brings_floating_windows_back() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut h, _) = probe(2, 0, true);
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap();
|
||||
assert_eq!(h.state().app.ui.windows.len(), 2);
|
||||
let (id, params) = click(&mut h, "Consolidate All to Here");
|
||||
assert_eq!((id, ¶ms), ("window.arrange.consolidateAllToTabs", &json!({})));
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
assert!(h.state().app.ui.windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_document_opens_the_new_document_dialog() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut h, _) = probe(1, 0, false);
|
||||
let (id, params) = click(&mut h, "New Document…");
|
||||
assert_eq!((id, ¶ms), ("file.new", &json!({})));
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
assert!(h.state().app.ui.dialogs.iter().any(|d| d.kind == DialogKind::NewDocument));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_document_asks_for_files_like_file_open() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut h, requests) = probe(1, 0, false);
|
||||
let (id, params) = click(&mut h, "Open Document…");
|
||||
assert_eq!((id, ¶ms), ("file.open", &json!({})));
|
||||
crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap();
|
||||
h.state_mut().app.poll_file_dialog(&ctx, None);
|
||||
let asked = requests.borrow();
|
||||
assert!(matches!(asked.as_slice(), [FileDialogRequest::Open { multiple: true, .. }]), "{asked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_keeps_its_platform_label_and_command() {
|
||||
let (mut h, _) = probe(1, 0, true);
|
||||
let (id, params) = click(&mut h, crate::menus::reveal_label());
|
||||
assert_eq!((id, ¶ms), ("file.revealInFinder", &json!({"document": 0})));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG
|
||||
//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command
|
||||
//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no
|
||||
//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and
|
||||
//! PNG has none.
|
||||
use crate::PhotocraftApp;
|
||||
use egui::accesskit::Role;
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const ARTBOARDS: &str = "file.export.artboardsToFiles";
|
||||
const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"];
|
||||
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default())
|
||||
});
|
||||
let app = h.state_mut();
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
// A noisy layer inside the board, so the JPEG quality shows in the file.
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap();
|
||||
app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap();
|
||||
app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap();
|
||||
app.run("layerComp.new", json!({"name": "Comp"})).unwrap();
|
||||
app.sync_views();
|
||||
h
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 {
|
||||
let ctx = h.ctx.clone();
|
||||
let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
|
||||
fields.insert("dir".into(), json!(dir));
|
||||
fields.insert("prefix".into(), json!("out"));
|
||||
fields.insert("format".into(), json!(format));
|
||||
h.run_steps(4);
|
||||
dialog
|
||||
}
|
||||
|
||||
fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value {
|
||||
crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields)
|
||||
}
|
||||
|
||||
fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec<u8> {
|
||||
let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The same export run straight through the command, into `dir`.
|
||||
fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option<i64>) -> Vec<u8> {
|
||||
let mut p = json!({"dir": dir, "prefix": "out", "format": format});
|
||||
if let Some(q) = quality {
|
||||
p["quality"] = json!(q);
|
||||
}
|
||||
let r = h.state_mut().run(ARTBOARDS, p).unwrap();
|
||||
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Whether the Quality number field (not its row label) takes input.
|
||||
fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool {
|
||||
let fields: Vec<bool> =
|
||||
h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect();
|
||||
assert!(!fields.is_empty(), "the dialog shows a Quality field");
|
||||
assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}");
|
||||
fields[0]
|
||||
}
|
||||
|
||||
/// Whether the Custom checkbox is ticked.
|
||||
fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool {
|
||||
h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)
|
||||
}
|
||||
|
||||
/// The value the Quality field shows.
|
||||
fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option<f64> {
|
||||
h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value()
|
||||
}
|
||||
|
||||
fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) {
|
||||
h.get_by_role_and_label(Role::CheckBox, "Custom").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Types `text` into the Quality field and commits it with Tab, as a user does.
|
||||
fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) {
|
||||
h.get_by_role_and_label(Role::SpinButton, "Quality").click();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(1);
|
||||
for ch in text.chars() {
|
||||
h.event(egui::Event::Text(ch.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Holds Alt, which turns Cancel into Reset, and clicks it.
|
||||
fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) {
|
||||
let alt = egui::Modifiers::ALT;
|
||||
h.event(egui::Event::ModifiersChanged(alt));
|
||||
h.run_steps(2);
|
||||
let at = h.get_by_label("Reset").rect().center();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn tmp() -> (tempfile::TempDir, String) {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let s = t.path().to_string_lossy().into_owned();
|
||||
(t, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_to_files_sends_a_custom_jpeg_quality() {
|
||||
let mut h = harness();
|
||||
let (_a, via_dialog) = tmp();
|
||||
let (_b, via_command) = tmp();
|
||||
let mut sizes = Vec::new();
|
||||
for quality in [0, 3, 12] {
|
||||
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
|
||||
assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out");
|
||||
tick_custom(&mut h);
|
||||
assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10");
|
||||
type_quality(&mut h, &quality.to_string());
|
||||
assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command");
|
||||
let bytes = exported(&mut h, dialog);
|
||||
assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality");
|
||||
sizes.push(bytes.len());
|
||||
}
|
||||
assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() {
|
||||
let mut h = harness();
|
||||
let (_a, via_dialog) = tmp();
|
||||
let (_b, via_command) = tmp();
|
||||
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
|
||||
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent");
|
||||
let bytes = exported(&mut h, dialog);
|
||||
assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_to_files_quality_leaves_png_and_webp_as_they_were() {
|
||||
let mut h = harness();
|
||||
let (_a, dir) = tmp();
|
||||
for id in EXPORTS {
|
||||
for format in ["png", "webp"] {
|
||||
let dialog = open(&mut h, id, &dir, format);
|
||||
// Custom on from an earlier JPEG choice still sends nothing for these formats.
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
|
||||
h.run_steps(2);
|
||||
assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out");
|
||||
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent");
|
||||
let bytes = exported(&mut h, dialog);
|
||||
if format == "webp" {
|
||||
// The default WebP stays lossless (a VP8L bitstream, not lossy VP8).
|
||||
assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless");
|
||||
}
|
||||
}
|
||||
// Back to JPEG with Custom on, the field sends its quality again.
|
||||
let dialog = open(&mut h, id, &dir, "jpg");
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
|
||||
h.run_steps(2);
|
||||
assert!(quality_enabled(&h), "{id}");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}");
|
||||
crate::dialogs::cancel(h.state_mut(), dialog).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A Custom quality survives a switch to another format and back in the same dialog.
|
||||
#[test]
|
||||
fn the_custom_quality_survives_a_format_round_trip() {
|
||||
let mut h = harness();
|
||||
let (_a, dir) = tmp();
|
||||
let dialog = open(&mut h, ARTBOARDS, &dir, "jpg");
|
||||
tick_custom(&mut h);
|
||||
type_quality(&mut h, "4");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
|
||||
for format in ["png", "webp"] {
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format));
|
||||
h.run_steps(2);
|
||||
assert!(!quality_enabled(&h), "{format}: the field is greyed out");
|
||||
assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent");
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
|
||||
h.run_steps(2);
|
||||
assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on");
|
||||
assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent");
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to
|
||||
/// how the dialog opened, and keeps the dialog open.
|
||||
#[test]
|
||||
fn alt_reset_restores_the_custom_quality() {
|
||||
let mut h = harness();
|
||||
let ctx = h.ctx.clone();
|
||||
let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
// JPEG chosen in the dialog, after it opened: Reset puts the format back too.
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
|
||||
h.run_steps(4);
|
||||
tick_custom(&mut h);
|
||||
type_quality(&mut h, "4");
|
||||
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
|
||||
alt_reset(&mut h);
|
||||
assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open");
|
||||
let p = params(&mut h, dialog);
|
||||
assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality");
|
||||
assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again");
|
||||
assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality");
|
||||
// Choosing JPEG again still sends no quality until Custom is ticked.
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
|
||||
h.run_steps(2);
|
||||
assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off");
|
||||
assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent");
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
Right يمين
|
||||
Open Recent فتح الملفات الأخيرة
|
||||
Clear Recent Files مسح الملفات الأخيرة
|
||||
Clear Recent File List مسح الملفات الأخيرة
|
||||
Remove from Recent إزالة من الملفات الأخيرة
|
||||
Remove {name} from Recent إزالة {name} من الملفات الأخيرة
|
||||
Close إغلاق
|
||||
@@ -89,6 +90,10 @@
|
||||
Restore استعادة
|
||||
Close All إغلاق الكل
|
||||
Close Others إغلاق المستندات الأخرى
|
||||
New Document… مستند جديد…
|
||||
Open Document… فتح…
|
||||
Consolidate All to Here توحيد الكل هنا
|
||||
Move to New Window نقل إلى نافذة جديدة
|
||||
Save حفظ
|
||||
Save As… حفظ باسم…
|
||||
Save a Copy… حفظ نسخة…
|
||||
@@ -141,6 +146,7 @@
|
||||
Create Smart Object after Loading Layers إنشاء كائن ذكي بعد تحميل الطبقات
|
||||
Reveal in Finder إظهار في Finder
|
||||
Show in Explorer إظهار في Explorer
|
||||
Reveal in Explorer إظهار في Explorer
|
||||
Show in Folder إظهار في المجلد
|
||||
File Info… معلومات الملف…
|
||||
Print… طباعة…
|
||||
@@ -229,6 +235,7 @@
|
||||
Units & Rulers… الوحدات والمساطر…
|
||||
Guides, Grid & Slices… خطوط التوجيه والشبكة والشرائح…
|
||||
Plug-ins… المكونات الإضافية…
|
||||
Plugins… المكونات الإضافية…
|
||||
Type… النص…
|
||||
Enhanced Controls… عناصر التحكم المحسنة…
|
||||
Camera Raw… Camera Raw…
|
||||
@@ -386,6 +393,8 @@
|
||||
Smart Object كائن ذكي
|
||||
Convert to Smart Object تحويل إلى كائن ذكي
|
||||
Convert to Embedded تحويل إلى مضمّن
|
||||
Embed Linked تضمين المرتبط
|
||||
Embed All Linked تضمين كل المرتبط
|
||||
Convert to Layers تحويل إلى طبقات
|
||||
Convert to Linked… تحويل إلى مرتبط…
|
||||
Edit Contents تحرير المحتويات
|
||||
@@ -459,7 +468,9 @@
|
||||
Unite Shapes توحيد الأشكال
|
||||
Subtract Front Shape طرح الشكل الأمامي
|
||||
Intersect Shape Areas تقاطع مناطق الأشكال
|
||||
Unite Shapes at Overlap تقاطع مناطق الأشكال
|
||||
Exclude Overlapping Shapes استبعاد الأشكال المتداخلة
|
||||
Subtract Shapes at Overlap استبعاد الأشكال المتداخلة
|
||||
Merge Shape Components دمج مكونات الشكل
|
||||
Align محاذاة
|
||||
Top Edges الحواف العلوية
|
||||
@@ -660,6 +671,7 @@
|
||||
Minimum… الحد الأدنى…
|
||||
Offset… إزاحة…
|
||||
Plug-ins المكونات الإضافية
|
||||
Plugins المكونات الإضافية
|
||||
Proof Setup إعداد المعاينة الطباعية
|
||||
Proof Colors ألوان المعاينة الطباعية
|
||||
Gamut Warning تحذير النطاق اللوني
|
||||
@@ -668,9 +680,9 @@
|
||||
Working Magenta Plate لوح الأرجواني العامل
|
||||
Working Yellow Plate لوح الأصفر العامل
|
||||
Working Black Plate لوح الأسود العامل
|
||||
Working CMY Plate لوح CMY العامل
|
||||
Legacy Macintosh RGB RGB القديم لنظام Macintosh
|
||||
Internet Standard RGB RGB القياسي للإنترنت
|
||||
Working CMY Plates لوح CMY العامل
|
||||
Legacy Macintosh RGB (Gamma 1.8) RGB القديم لنظام Macintosh (Gamma 1.8)
|
||||
Internet Standard RGB (sRGB) RGB القياسي للإنترنت (sRGB)
|
||||
Monitor RGB RGB الشاشة
|
||||
Color Blindness — Protanopia-type عمى الألوان — نوع بروتانوبيا
|
||||
Color Blindness — Deuteranopia-type عمى الألوان — نوع ديوتيرانوبيا
|
||||
@@ -752,7 +764,7 @@
|
||||
Consolidate All to Tabs توحيد الكل في علامات تبويب
|
||||
Float in Window تعويم في نافذة
|
||||
Float All in Windows تعويم الكل في نوافذ
|
||||
New Window for Document نافذة جديدة للمستند
|
||||
New Window نافذة جديدة للمستند
|
||||
Workspace مساحة العمل
|
||||
Essentials الأساسيات
|
||||
Graphic and Web الرسومات والويب
|
||||
@@ -1924,6 +1936,11 @@
|
||||
Show canvas dots إظهار نقاط مساحة الرسم
|
||||
Clip thumbnails to layer bounds قصر الصور المصغرة على حدود الطبقة
|
||||
Clip Thumbnails to Layer Bounds قصر الصور المصغرة على حدود الطبقة
|
||||
No Thumbnails بلا صور مصغرة
|
||||
Small Thumbnails صور مصغرة صغيرة
|
||||
Medium Thumbnails صور مصغرة متوسطة
|
||||
Large Thumbnails صور مصغرة كبيرة
|
||||
Clip Thumbnails to Document Bounds قصر الصور المصغرة على حدود المستند
|
||||
Canvas color لون مساحة الرسم
|
||||
Canvas custom color لون مخصص لمساحة الرسم
|
||||
Color picker منتقي الألوان
|
||||
@@ -3564,3 +3581,18 @@ curves Smooth تنعيم
|
||||
Pause For إيقاف مؤقت لمدة
|
||||
seconds ثانية
|
||||
Create a single history state إنشاء حالة سجل واحدة
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
New Channel… قناة جديدة…
|
||||
Duplicate Channel تكرار القناة
|
||||
Delete Channel حذف القناة
|
||||
New Spot Channel… قناة لون موضعي جديدة…
|
||||
Merge Spot Channel دمج قناة اللون الموضعي
|
||||
Channel Options… خيارات القناة…
|
||||
Split Channels تقسيم القنوات
|
||||
Merge Channels… دمج القنوات…
|
||||
Specify Channels: تحديد القنوات:
|
||||
Not enough grayscale documents for this mode. لا توجد مستندات كافية بتدرج الرمادي لهذا الوضع.
|
||||
Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة.
|
||||
A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا.
|
||||
A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -64,6 +64,7 @@
|
||||
Right Desno
|
||||
Open Recent Otvori nedavno
|
||||
Clear Recent Files Očisti nedavne datoteke
|
||||
Clear Recent File List Očisti nedavne datoteke
|
||||
Remove from Recent Ukloni iz nedavnih
|
||||
Remove {name} from Recent Ukloni {name} iz nedavnih
|
||||
Close Zatvori
|
||||
@@ -72,6 +73,10 @@
|
||||
Restore Vrati
|
||||
Close All Zatvori sve
|
||||
Close Others Zatvori ostale
|
||||
New Document… Novi dokument…
|
||||
Open Document… Otvori…
|
||||
Consolidate All to Here Objedini sve ovdje
|
||||
Move to New Window Premjesti u novi prozor
|
||||
Save Sačuvaj
|
||||
Save As… Sačuvaj kao…
|
||||
Save a Copy… Sačuvaj kopiju…
|
||||
@@ -123,6 +128,7 @@
|
||||
Create Smart Object after Loading Layers Kreiraj pametni objekat nakon učitavanja slojeva
|
||||
Reveal in Finder Prikaži u Finderu
|
||||
Show in Explorer Prikaži u Exploreru
|
||||
Reveal in Explorer Prikaži u Exploreru
|
||||
Show in Folder Prikaži u folderu
|
||||
File Info… Informacije o datoteci…
|
||||
Print… Štampaj…
|
||||
@@ -207,6 +213,7 @@
|
||||
Units & Rulers… Jedinice i ravnala…
|
||||
Guides, Grid & Slices… Vodilice, mreža i isječci…
|
||||
Plug-ins… Dodaci…
|
||||
Plugins… Dodaci…
|
||||
Type… Vrsta…
|
||||
Enhanced Controls… Poboljšane kontrole…
|
||||
Camera Raw… Camera Raw…
|
||||
@@ -355,6 +362,8 @@
|
||||
Smart Object Pametni objekat
|
||||
Convert to Smart Object Pretvori u pametni objekat
|
||||
Convert to Embedded Pretvori u ugrađeno
|
||||
Embed Linked Ugradi povezano
|
||||
Embed All Linked Ugradi sve povezano
|
||||
Convert to Layers Pretvori u slojeve
|
||||
Convert to Linked… Pretvori u povezano…
|
||||
Edit Contents Uredi sadržaj
|
||||
@@ -605,6 +614,7 @@
|
||||
Minimum… Minimum…
|
||||
Offset… Pomak…
|
||||
Plug-ins Dodaci
|
||||
Plugins Dodaci
|
||||
Proof Setup Postavke probnog prikaza
|
||||
Proof Colors Probne boje
|
||||
Gamut Warning Upozorenje o gamutu
|
||||
@@ -613,9 +623,9 @@
|
||||
Working Magenta Plate Radna magenta ploča
|
||||
Working Yellow Plate Radna žuta ploča
|
||||
Working Black Plate Radna crna ploča
|
||||
Working CMY Plate Radna CMY ploča
|
||||
Legacy Macintosh RGB Naslijeđeni Macintosh RGB
|
||||
Internet Standard RGB Internet standardni RGB
|
||||
Working CMY Plates Radna CMY ploča
|
||||
Legacy Macintosh RGB (Gamma 1.8) Naslijeđeni Macintosh RGB (Gamma 1.8)
|
||||
Internet Standard RGB (sRGB) Internet standardni RGB (sRGB)
|
||||
Monitor RGB RGB monitora
|
||||
Color Blindness — Protanopia-type Daltonizam — tip protanopije
|
||||
Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije
|
||||
@@ -707,7 +717,6 @@
|
||||
Consolidate All to Tabs Objedini sve u kartice
|
||||
Float in Window Odvoji u prozor
|
||||
Float All in Windows Odvoji sve u prozore
|
||||
New Window for Document Novi prozor za dokument
|
||||
Workspace Radni prostor
|
||||
Essentials Osnove
|
||||
Graphic and Web Grafika i web
|
||||
@@ -1851,6 +1860,11 @@
|
||||
Show canvas dots Prikaži tačke platna
|
||||
Clip thumbnails to layer bounds Ograniči sličice na granice sloja
|
||||
Clip Thumbnails to Layer Bounds Ograniči sličice na granice sloja
|
||||
No Thumbnails Bez sličica
|
||||
Small Thumbnails Male sličice
|
||||
Medium Thumbnails Srednje sličice
|
||||
Large Thumbnails Velike sličice
|
||||
Clip Thumbnails to Document Bounds Ograniči sličice na granice dokumenta
|
||||
Canvas color Boja platna
|
||||
Canvas custom color Prilagođena boja platna
|
||||
Color picker Birač boja
|
||||
@@ -4605,3 +4619,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Stop Color Boja tačke
|
||||
Shapes can't use patterns yet Oblici još ne mogu koristiti uzorke
|
||||
Edit Brush… Uredi četkicu…
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
Channel Options… Opcije kanala…
|
||||
Specify Channels: Odredi kanale:
|
||||
Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj način.
|
||||
Pick a different document for each channel. Odaberite drugi dokument za svaki kanal.
|
||||
A picked document is no longer open. Jedan odabrani dokument više nije otvoren.
|
||||
A picked document is no longer a grayscale document of the same size. Jedan odabrani dokument više nije dokument u nijansama sive iste veličine.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -203,6 +203,7 @@
|
||||
Right Vpravo
|
||||
Open Recent Otevřít nedávné
|
||||
Clear Recent Files Vymazat nedávné soubory
|
||||
Clear Recent File List Vymazat nedávné soubory
|
||||
Remove from Recent Odebrat z nedávných
|
||||
Remove {name} from Recent Odebrat {name} z nedávných
|
||||
Close Zavřít
|
||||
@@ -211,6 +212,10 @@
|
||||
Restore Obnovit
|
||||
Close All Zavřít vše
|
||||
Close Others Zavřít ostatní
|
||||
New Document… Nový dokument…
|
||||
Open Document… Otevřít…
|
||||
Consolidate All to Here Sloučit vše sem
|
||||
Move to New Window Přesunout do nového okna
|
||||
Save Uložit
|
||||
Save As… Uložit jako…
|
||||
Save a Copy… Uložit kopii…
|
||||
@@ -262,6 +267,7 @@
|
||||
Create Smart Object after Loading Layers Po načtení vrstev vytvořit chytrý objekt
|
||||
Reveal in Finder Zobrazit ve Finderu
|
||||
Show in Explorer Zobrazit v Průzkumníkovi
|
||||
Reveal in Explorer Zobrazit v Průzkumníkovi
|
||||
Show in Folder Zobrazit ve složce
|
||||
File Info… Informace o souboru…
|
||||
Print… Tisk…
|
||||
@@ -350,6 +356,7 @@
|
||||
Units & Rulers… Jednotky a pravítka…
|
||||
Guides, Grid & Slices… Vodítka, mřížka a řezy…
|
||||
Plug-ins… Zásuvné moduly…
|
||||
Plugins… Zásuvné moduly…
|
||||
Type… Text…
|
||||
Enhanced Controls… Rozšířené ovládání…
|
||||
Camera Raw… Camera Raw…
|
||||
@@ -507,6 +514,8 @@
|
||||
Smart Object Chytrý objekt
|
||||
Convert to Smart Object Převést na chytrý objekt
|
||||
Convert to Embedded Převést na vložený
|
||||
Embed Linked Vložit propojené
|
||||
Embed All Linked Vložit všechna propojená
|
||||
Convert to Layers Převést na vrstvy
|
||||
Convert to Linked… Převést na propojený…
|
||||
Edit Contents Upravit obsah
|
||||
@@ -764,6 +773,7 @@
|
||||
Minimum… Minimum…
|
||||
Offset… Posun…
|
||||
Plug-ins Zásuvné moduly
|
||||
Plugins Zásuvné moduly
|
||||
Proof Setup Nastavení nátisku
|
||||
Proof Colors Nátiskové barvy
|
||||
Gamut Warning Upozornění na gamut
|
||||
@@ -772,9 +782,9 @@
|
||||
Working Magenta Plate Pracovní purpurový výtažek
|
||||
Working Yellow Plate Pracovní žlutý výtažek
|
||||
Working Black Plate Pracovní černý výtažek
|
||||
Working CMY Plate Pracovní výtažky CMY
|
||||
Legacy Macintosh RGB Starší Macintosh RGB
|
||||
Internet Standard RGB Internetový standard RGB
|
||||
Working CMY Plates Pracovní výtažky CMY
|
||||
Legacy Macintosh RGB (Gamma 1.8) Starší Macintosh RGB (Gamma 1.8)
|
||||
Internet Standard RGB (sRGB) Internetový standard RGB (sRGB)
|
||||
Monitor RGB RGB monitoru
|
||||
Color Blindness — Protanopia-type Barvoslepost — protanopie
|
||||
Color Blindness — Deuteranopia-type Barvoslepost — deuteranopie
|
||||
@@ -866,7 +876,7 @@
|
||||
Consolidate All to Tabs Sloučit vše do záložek
|
||||
Float in Window Uvolnit do plovoucího okna
|
||||
Float All in Windows Uvolnit vše do plovoucích oken
|
||||
New Window for Document Nové okno dokumentu
|
||||
New Window Nové okno dokumentu
|
||||
Workspace Pracovní prostor
|
||||
Essentials Základy
|
||||
Graphic and Web Grafika a web
|
||||
@@ -2041,6 +2051,11 @@
|
||||
Show canvas dots Zobrazit tečky plátna
|
||||
Clip thumbnails to layer bounds Oříznout miniatury na hranice vrstvy
|
||||
Clip Thumbnails to Layer Bounds Oříznout miniatury na hranice vrstvy
|
||||
No Thumbnails Bez miniatur
|
||||
Small Thumbnails Malé miniatury
|
||||
Medium Thumbnails Střední miniatury
|
||||
Large Thumbnails Velké miniatury
|
||||
Clip Thumbnails to Document Bounds Oříznout miniatury na hranice dokumentu
|
||||
Canvas color Barva plátna
|
||||
Canvas custom color Vlastní barva plátna
|
||||
Color picker Výběr barvy
|
||||
@@ -3910,6 +3925,9 @@ navigator Zoom Zvětšení
|
||||
Sent to {printer} Odesláno do {printer}
|
||||
set in Print Settings… nastavuje se v nastavení tisku…
|
||||
ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů
|
||||
Automatically Create First Snapshot Automaticky vytvořit první snímek
|
||||
Automatically Create New Snapshot When Saving Automaticky vytvořit nový snímek při ukládání
|
||||
Show New Snapshot Dialog by Default Ve výchozím nastavení zobrazit dialog Nový snímek
|
||||
Memory usage percent Využití paměti (%)
|
||||
Show Favorite Fonts Zobrazit oblíbená písma
|
||||
No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj.
|
||||
@@ -4313,3 +4331,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Replace Image… Nahradit obrázek…
|
||||
The frame is locked Rámeček je zamčený
|
||||
The frame is unavailable Rámeček není dostupný
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
Channel Options… Možnosti kanálu…
|
||||
Specify Channels: Určit kanály:
|
||||
Not enough grayscale documents for this mode. Pro tento režim není dost dokumentů ve stupních šedi.
|
||||
Pick a different document for each channel. Pro každý kanál vyberte jiný dokument.
|
||||
A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený.
|
||||
A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -86,6 +86,7 @@
|
||||
Right Højre
|
||||
Open Recent Åbn seneste
|
||||
Clear Recent Files Ryd seneste filer
|
||||
Clear Recent File List Ryd seneste filer
|
||||
Remove from Recent Fjern fra seneste
|
||||
Remove {name} from Recent Fjern {name} fra seneste
|
||||
Close Luk
|
||||
@@ -94,6 +95,10 @@
|
||||
Restore Gendan
|
||||
Close All Luk alle
|
||||
Close Others Luk andre
|
||||
New Document… Nyt dokument…
|
||||
Open Document… Åbn…
|
||||
Consolidate All to Here Saml alle her
|
||||
Move to New Window Flyt til nyt vindue
|
||||
Save Gem
|
||||
Save As… Gem som…
|
||||
Save a Copy… Gem en kopi…
|
||||
@@ -508,7 +513,6 @@
|
||||
Match Location Afstem placering
|
||||
Match Rotation Afstem rotation
|
||||
Match All Afstem alle
|
||||
New Window for Document Nyt vindue til dokument
|
||||
Workspace Arbejdsområde
|
||||
Extensions Udvidelser
|
||||
Panels Paneler
|
||||
@@ -686,6 +690,7 @@
|
||||
Units & Rulers… Enheder og linealer…
|
||||
Guides, Grid & Slices… Hjælpelinjer, gitter og udsnit…
|
||||
Plug-ins… Plug-ins…
|
||||
Plugins… Plug-ins…
|
||||
Technology Previews Teknologieksempler
|
||||
Scratch Disks… Arbejdsdiske…
|
||||
Export Preferences… Eksportpræferencer…
|
||||
@@ -920,8 +925,8 @@
|
||||
ACES ACES
|
||||
Industry Standard Industristandard
|
||||
Web Web
|
||||
Internet Standard RGB Internetstandard-RGB
|
||||
Legacy Macintosh RGB Ældre Macintosh RGB
|
||||
Internet Standard RGB (sRGB) Internetstandard-RGB (sRGB)
|
||||
Legacy Macintosh RGB (Gamma 1.8) Ældre Macintosh RGB (Gamma 1.8)
|
||||
Monitor RGB Skærm-RGB
|
||||
Windows LCD Windows LCD
|
||||
Coated CMYK Bestrøget CMYK
|
||||
@@ -1742,6 +1747,7 @@
|
||||
Saved Gemt
|
||||
Reveal in Finder Vis i Finder
|
||||
Show in Explorer Vis i Stifinder
|
||||
Reveal in Explorer Vis i Stifinder
|
||||
Show in Folder Vis i mappe
|
||||
Before (Y) Før (Y)
|
||||
Save Settings… Gem indstillinger…
|
||||
@@ -1921,6 +1927,8 @@
|
||||
Combine Shapes Kombinér former
|
||||
Contract… Formindsk…
|
||||
Convert to Embedded Konvertér til indlejret
|
||||
Embed Linked Indlejr sammenkædet
|
||||
Embed All Linked Indlejr alle sammenkædede
|
||||
Convert to Layers Konvertér til lag
|
||||
Convert to Linked… Konvertér til sammenkædet…
|
||||
Count Optælling
|
||||
@@ -2008,6 +2016,7 @@
|
||||
Pixelate Pixelér
|
||||
Place Scale Marker… Placér skalamarkør…
|
||||
Plug-ins Plug-ins
|
||||
Plugins Plug-ins
|
||||
Print One Copy Udskriv én kopi
|
||||
Print… Udskriv…
|
||||
Range Spændvidde
|
||||
@@ -2052,7 +2061,7 @@
|
||||
Vertical Centers Lodrette midtpunkter
|
||||
Video Frames to Layers… Videobilleder til lag…
|
||||
WIA Support… WIA-support…
|
||||
Working CMY Plate Arbejds-CMY-plade
|
||||
Working CMY Plates Arbejds-CMY-plade
|
||||
|
||||
# tl! literals (third pass)
|
||||
Absolute Absolut
|
||||
@@ -2338,6 +2347,11 @@
|
||||
Show canvas dots Vis lærredsprikker
|
||||
Clip thumbnails to layer bounds Klip miniaturer til laggrænser
|
||||
Clip Thumbnails to Layer Bounds Klip miniaturer til laggrænser
|
||||
No Thumbnails Ingen miniaturer
|
||||
Small Thumbnails Små miniaturer
|
||||
Medium Thumbnails Mellemstore miniaturer
|
||||
Large Thumbnails Store miniaturer
|
||||
Clip Thumbnails to Document Bounds Klip miniaturer til dokumentgrænser
|
||||
Canvas color Lærredsfarve
|
||||
Canvas custom color Brugerdefineret lærredsfarve
|
||||
Classic Klassisk
|
||||
@@ -4787,3 +4801,11 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet
|
||||
Pause For Pause i
|
||||
seconds sekunder
|
||||
Create a single history state Opret én historiktilstand
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
Channel Options… Kanalindstillinger…
|
||||
Specify Channels: Angiv kanaler:
|
||||
Not enough grayscale documents for this mode. Der er ikke nok dokumenter i gråtoner til denne tilstand.
|
||||
Pick a different document for each channel. Vælg et andet dokument til hver kanal.
|
||||
A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent.
|
||||
A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -173,6 +173,7 @@
|
||||
Right Rechts
|
||||
Open Recent Zuletzt geöffnete Dateien
|
||||
Clear Recent Files Zuletzt geöffnete Dateien leeren
|
||||
Clear Recent File List Zuletzt geöffnete Dateien leeren
|
||||
Remove from Recent Aus zuletzt geöffneten Dateien entfernen
|
||||
Remove {name} from Recent {name} aus zuletzt geöffneten Dateien entfernen
|
||||
Close Schließen
|
||||
@@ -181,6 +182,10 @@
|
||||
Restore Wiederherstellen
|
||||
Close All Alle schließen
|
||||
Close Others Andere schließen
|
||||
New Document… Neues Dokument…
|
||||
Open Document… Öffnen…
|
||||
Consolidate All to Here Alle hier zusammenfassen
|
||||
Move to New Window In neues Fenster verschieben
|
||||
Save Speichern
|
||||
Save As… Speichern unter…
|
||||
Save a Copy… Kopie speichern…
|
||||
@@ -233,6 +238,7 @@
|
||||
Create Smart Object after Loading Layers Nach dem Laden der Ebenen Smart-Objekt erstellen
|
||||
Reveal in Finder Im Finder anzeigen
|
||||
Show in Explorer Im Explorer anzeigen
|
||||
Reveal in Explorer Im Explorer anzeigen
|
||||
Show in Folder Im Ordner anzeigen
|
||||
File Info… Dateiinformationen…
|
||||
Print… Drucken…
|
||||
@@ -321,6 +327,7 @@
|
||||
Units & Rulers… Einheiten & Lineale…
|
||||
Guides, Grid & Slices… Hilfslinien, Raster & Slices…
|
||||
Plug-ins… Plug-ins…
|
||||
Plugins… Plug-ins…
|
||||
Type… Text…
|
||||
Enhanced Controls… Erweiterte Bedienelemente…
|
||||
Camera Raw… Camera Raw…
|
||||
@@ -478,6 +485,8 @@
|
||||
Smart Object Smart-Objekt
|
||||
Convert to Smart Object In Smart-Objekt konvertieren
|
||||
Convert to Embedded In eingebettetes Smart-Objekt konvertieren
|
||||
Embed Linked Verknüpftes einbetten
|
||||
Embed All Linked Alle verknüpften einbetten
|
||||
Convert to Layers In Ebenen konvertieren
|
||||
Convert to Linked… In verknüpftes Smart-Objekt konvertieren…
|
||||
Edit Contents Inhalt bearbeiten
|
||||
@@ -551,7 +560,9 @@
|
||||
Unite Shapes Formen vereinigen
|
||||
Subtract Front Shape Vordere Form subtrahieren
|
||||
Intersect Shape Areas Schnittmenge der Formen
|
||||
Unite Shapes at Overlap Schnittmenge der Formen
|
||||
Exclude Overlapping Shapes Überlappende Formen ausschließen
|
||||
Subtract Shapes at Overlap Überlappende Formen ausschließen
|
||||
Merge Shape Components Formkomponenten zusammenfügen
|
||||
Path operations Pfadvorgänge
|
||||
Align Ausrichten
|
||||
@@ -735,6 +746,7 @@
|
||||
Minimum… Minimum…
|
||||
Offset… Versatz…
|
||||
Plug-ins Plug-ins
|
||||
Plugins Plug-ins
|
||||
Proof Setup Proof-Setup
|
||||
Proof Colors Proof-Farben
|
||||
Gamut Warning Farbumfang-Warnung
|
||||
@@ -743,9 +755,9 @@
|
||||
Working Magenta Plate Arbeits-Magenta-Druckplatte
|
||||
Working Yellow Plate Arbeits-Gelb-Druckplatte
|
||||
Working Black Plate Arbeits-Schwarz-Druckplatte
|
||||
Working CMY Plate Arbeits-CMY-Druckplatte
|
||||
Legacy Macintosh RGB Legacy-Macintosh-RGB
|
||||
Internet Standard RGB Internet-Standard-RGB
|
||||
Working CMY Plates Arbeits-CMY-Druckplatte
|
||||
Legacy Macintosh RGB (Gamma 1.8) Legacy-Macintosh-RGB (Gamma 1.8)
|
||||
Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB)
|
||||
Monitor RGB Monitor-RGB
|
||||
Color Blindness — Protanopia-type Farbenblindheit — Protanopie-Typ
|
||||
Color Blindness — Deuteranopia-type Farbenblindheit — Deuteranopie-Typ
|
||||
@@ -827,7 +839,7 @@
|
||||
Consolidate All to Tabs Alle in Registerkarten zusammenfassen
|
||||
Float in Window In Fenster schweben lassen
|
||||
Float All in Windows Alle in Fenstern schweben lassen
|
||||
New Window for Document Neues Fenster für Dokument
|
||||
New Window Neues Fenster für Dokument
|
||||
Workspace Arbeitsbereich
|
||||
Essentials Essentials
|
||||
Graphic and Web Grafik und Web
|
||||
@@ -2000,6 +2012,11 @@
|
||||
Show canvas dots Punkte der Zeichenfläche anzeigen
|
||||
Clip thumbnails to layer bounds Miniaturbilder auf Ebenenbegrenzung beschneiden
|
||||
Clip Thumbnails to Layer Bounds Miniaturbilder auf Ebenenbegrenzung beschneiden
|
||||
No Thumbnails Keine Miniaturbilder
|
||||
Small Thumbnails Kleine Miniaturbilder
|
||||
Medium Thumbnails Mittlere Miniaturbilder
|
||||
Large Thumbnails Große Miniaturbilder
|
||||
Clip Thumbnails to Document Bounds Miniaturbilder auf Dokumentbegrenzung beschneiden
|
||||
Canvas color Zeichenflächenfarbe
|
||||
Canvas custom color Benutzerdefinierte Zeichenflächenfarbe
|
||||
Color picker Farbwähler
|
||||
@@ -3309,6 +3326,9 @@ curves Smooth Glätten
|
||||
Sent to {printer} An {printer} gesendet
|
||||
set in Print Settings… in den Druckeinstellungen festlegen…
|
||||
ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt
|
||||
Automatically Create First Snapshot Ersten Snapshot automatisch erstellen
|
||||
Automatically Create New Snapshot When Saving Beim Speichern automatisch neuen Snapshot erstellen
|
||||
Show New Snapshot Dialog by Default Dialogfeld „Neuer Snapshot“ standardmäßig anzeigen
|
||||
Memory usage percent Speichernutzung (%)
|
||||
Show Favorite Fonts Favorisierte Schriften anzeigen
|
||||
No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen.
|
||||
@@ -3599,3 +3619,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
The frame is unavailable Der Rahmen ist nicht verfügbar
|
||||
X X
|
||||
Y Y
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
New Channel… Neuer Kanal…
|
||||
Duplicate Channel Kanal duplizieren
|
||||
Delete Channel Kanal löschen
|
||||
New Spot Channel… Neuer Schmuckfarbenkanal…
|
||||
Merge Spot Channel Schmuckfarbenkanal zusammenfügen
|
||||
Channel Options… Kanaloptionen…
|
||||
Split Channels Kanäle teilen
|
||||
Merge Channels… Kanäle zusammenfügen…
|
||||
Specify Channels: Kanäle angeben:
|
||||
Not enough grayscale documents for this mode. Nicht genug Graustufen-Dokumente für diesen Modus.
|
||||
Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument.
|
||||
A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet.
|
||||
A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -144,6 +144,7 @@
|
||||
Right Δεξιά
|
||||
Open Recent Πρόσφατα αρχεία
|
||||
Clear Recent Files Εκκαθάριση πρόσφατων αρχείων
|
||||
Clear Recent File List Εκκαθάριση πρόσφατων αρχείων
|
||||
Remove from Recent Αφαίρεση από τα πρόσφατα
|
||||
Remove {name} from Recent Αφαίρεση του {name} από τα πρόσφατα
|
||||
Close Κλείσιμο
|
||||
@@ -152,6 +153,10 @@
|
||||
Restore Επαναφορά
|
||||
Close All Κλείσιμο όλων
|
||||
Close Others Κλείσιμο των υπόλοιπων
|
||||
New Document… Νέο έγγραφο…
|
||||
Open Document… Άνοιγμα…
|
||||
Consolidate All to Here Συγκέντρωση όλων εδώ
|
||||
Move to New Window Μετακίνηση σε νέο παράθυρο
|
||||
Save Αποθήκευση
|
||||
Save As… Αποθήκευση ως…
|
||||
Save a Copy… Αποθήκευση αντιγράφου…
|
||||
@@ -203,6 +208,7 @@
|
||||
Create Smart Object after Loading Layers Δημιουργία έξυπνου αντικειμένου μετά τη φόρτωση επιπέδων
|
||||
Reveal in Finder Εμφάνιση στο Finder
|
||||
Show in Explorer Εμφάνιση στην Εξερεύνηση αρχείων
|
||||
Reveal in Explorer Εμφάνιση στην Εξερεύνηση αρχείων
|
||||
Show in Folder Εμφάνιση στον φάκελο
|
||||
File Info… Πληροφορίες αρχείου…
|
||||
Print… Εκτύπωση…
|
||||
@@ -1766,6 +1772,11 @@
|
||||
Show canvas dots Εμφάνιση κουκκίδων καμβά
|
||||
Clip thumbnails to layer bounds Περικοπή μικρογραφιών στα όρια του επιπέδου
|
||||
Clip Thumbnails to Layer Bounds Περικοπή μικρογραφιών στα όρια του επιπέδου
|
||||
No Thumbnails Χωρίς μικρογραφίες
|
||||
Small Thumbnails Μικρές μικρογραφίες
|
||||
Medium Thumbnails Μεσαίες μικρογραφίες
|
||||
Large Thumbnails Μεγάλες μικρογραφίες
|
||||
Clip Thumbnails to Document Bounds Περικοπή μικρογραφιών στα όρια του εγγράφου
|
||||
Canvas color Χρώμα καμβά
|
||||
Canvas custom color Προσαρμοσμένο χρώμα καμβά
|
||||
Color picker Επιλογέας χρώματος
|
||||
@@ -1847,6 +1858,7 @@
|
||||
Units & Rulers… Μονάδες και χάρακες…
|
||||
Guides, Grid & Slices… Οδηγοί, πλέγμα και τομές…
|
||||
Plug-ins… Πρόσθετα…
|
||||
Plugins… Πρόσθετα…
|
||||
Type… Κείμενο…
|
||||
Enhanced Controls… Προηγμένα χειριστήρια…
|
||||
Raw Defaults… Προεπιλογές RAW…
|
||||
@@ -1951,6 +1963,8 @@
|
||||
Hide Selection Απόκρυψη επιλογής
|
||||
From Transparency Από διαφάνεια
|
||||
Convert to Embedded Μετατροπή σε ενσωματωμένο
|
||||
Embed Linked Ενσωμάτωση συνδεδεμένου
|
||||
Embed All Linked Ενσωμάτωση όλων των συνδεδεμένων
|
||||
Convert to Linked… Μετατροπή σε συνδεδεμένο…
|
||||
Edit Contents Επεξεργασία περιεχομένου
|
||||
Replace Contents… Αντικατάσταση περιεχομένου…
|
||||
@@ -2009,7 +2023,9 @@
|
||||
Unite Shapes Ένωση σχημάτων
|
||||
Subtract Front Shape Αφαίρεση μπροστινού σχήματος
|
||||
Intersect Shape Areas Τομή περιοχών σχημάτων
|
||||
Unite Shapes at Overlap Τομή περιοχών σχημάτων
|
||||
Exclude Overlapping Shapes Εξαίρεση επικαλυπτόμενων σχημάτων
|
||||
Subtract Shapes at Overlap Εξαίρεση επικαλυπτόμενων σχημάτων
|
||||
Merge Shape Components Συγχώνευση συστατικών σχήματος
|
||||
Path operations Λειτουργίες διαδρομών
|
||||
Top Edges Επάνω ακμές
|
||||
@@ -2665,7 +2681,7 @@
|
||||
Install Plug-in Εγκατάσταση προσθέτου
|
||||
Integrations Ενσωματώσεις
|
||||
Interlaced Πεπλεγμένο
|
||||
Internet Standard RGB Τυπικό RGB διαδικτύου
|
||||
Internet Standard RGB (sRGB) Τυπικό RGB διαδικτύου (sRGB)
|
||||
Interpolation: Παρεμβολή:
|
||||
Interpret Footage Ερμηνεία πλάνων
|
||||
Intersect with selection ({key}) Τομή με την επιλογή ({key})
|
||||
@@ -2718,7 +2734,7 @@
|
||||
Leave as is (don't color manage) Διατήρηση ως έχει (χωρίς διαχείριση χρώματος)
|
||||
Left align text Αριστερή στοίχιση κειμένου
|
||||
Legacy Παλαιού τύπου
|
||||
Legacy Macintosh RGB Παλαιό Macintosh RGB
|
||||
Legacy Macintosh RGB (Gamma 1.8) Παλαιό Macintosh RGB (Gamma 1.8)
|
||||
Legacy compositing Παλαιός τρόπος σύνθεσης
|
||||
Legal Μέγεθος Legal
|
||||
Length: Μήκος:
|
||||
@@ -2855,7 +2871,6 @@
|
||||
New Video Group Νέα ομάδα βίντεο
|
||||
New Video Layer Νέο επίπεδο βίντεο
|
||||
New Video Layer from File Νέο επίπεδο βίντεο από αρχείο
|
||||
New Window for Document Νέο παράθυρο για το έγγραφο
|
||||
New Workspace Νέος χώρος εργασίας
|
||||
New Workspace… Νέος χώρος εργασίας…
|
||||
New document… Νέο έγγραφο…
|
||||
@@ -2948,6 +2963,7 @@
|
||||
Play Αναπαραγωγή
|
||||
Play selection Αναπαραγωγή επιλογής
|
||||
Plug-ins Πρόσθετα
|
||||
Plugins Πρόσθετα
|
||||
Point size Μέγεθος σε σημεία
|
||||
Pointillize Πουαντιγισμός
|
||||
Pointillize… Πουαντιγισμός…
|
||||
@@ -3371,7 +3387,7 @@
|
||||
Word Spacing Διάστημα λέξεων
|
||||
Work Path Διαδρομή εργασίας
|
||||
Working Black Plate Κανάλι μαύρου εργασίας
|
||||
Working CMY Plate Κανάλια CMY εργασίας
|
||||
Working CMY Plates Κανάλια CMY εργασίας
|
||||
Working CMYK Χώρος εργασίας CMYK
|
||||
Working Cyan Plate Κανάλι κυανού εργασίας
|
||||
Working Gray Plate Κανάλι γκρι εργασίας
|
||||
@@ -4268,3 +4284,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
The frame is unavailable Το πλαίσιο δεν είναι διαθέσιμο
|
||||
X X
|
||||
Y Y
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
New Channel… Νέο κανάλι…
|
||||
Duplicate Channel Δημιουργία αντιγράφου καναλιού
|
||||
Delete Channel Διαγραφή καναλιού
|
||||
New Spot Channel… Νέο κανάλι πλακάτου χρώματος…
|
||||
Merge Spot Channel Συγχώνευση καναλιού πλακάτου χρώματος
|
||||
Channel Options… Επιλογές καναλιού…
|
||||
Split Channels Διαχωρισμός καναλιών
|
||||
Merge Channels… Συγχώνευση καναλιών…
|
||||
Specify Channels: Καθορισμός καναλιών:
|
||||
Not enough grayscale documents for this mode. Δεν υπάρχουν αρκετά έγγραφα σε κλίμακα του γκρι για αυτή τη λειτουργία.
|
||||
Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι.
|
||||
A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό.
|
||||
A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -139,6 +139,7 @@
|
||||
Right Derecha
|
||||
Open Recent Abrir recientes
|
||||
Clear Recent Files Vaciar archivos recientes
|
||||
Clear Recent File List Vaciar archivos recientes
|
||||
Remove from Recent Quitar de recientes
|
||||
Remove {name} from Recent Quitar {name} de recientes
|
||||
Close Cerrar
|
||||
@@ -147,6 +148,10 @@
|
||||
Restore Restaurar
|
||||
Close All Cerrar todo
|
||||
Close Others Cerrar otros
|
||||
New Document… Documento nuevo…
|
||||
Open Document… Abrir…
|
||||
Consolidate All to Here Consolidar todo aquí
|
||||
Move to New Window Mover a una nueva ventana
|
||||
Save Guardar
|
||||
Save As… Guardar como…
|
||||
Save a Copy… Guardar una copia…
|
||||
@@ -199,6 +204,7 @@
|
||||
Create Smart Object after Loading Layers Crear objeto inteligente tras cargar las capas
|
||||
Reveal in Finder Mostrar en Finder
|
||||
Show in Explorer Mostrar en el Explorador
|
||||
Reveal in Explorer Mostrar en el Explorador
|
||||
Show in Folder Mostrar en la carpeta
|
||||
File Info… Información del archivo…
|
||||
Print… Imprimir…
|
||||
@@ -287,6 +293,7 @@
|
||||
Units & Rulers… Unidades y reglas…
|
||||
Guides, Grid & Slices… Guías, cuadrícula y sectores…
|
||||
Plug-ins… Plugins…
|
||||
Plugins… Plugins…
|
||||
Type… Texto…
|
||||
Enhanced Controls… Controles mejorados…
|
||||
Camera Raw… Camera Raw…
|
||||
@@ -444,6 +451,8 @@
|
||||
Smart Object Objeto inteligente
|
||||
Convert to Smart Object Convertir en objeto inteligente
|
||||
Convert to Embedded Convertir en incrustado
|
||||
Embed Linked Incrustar vinculado
|
||||
Embed All Linked Incrustar todos los vinculados
|
||||
Convert to Layers Convertir en capas
|
||||
Convert to Linked… Convertir en vinculado…
|
||||
Edit Contents Editar contenido
|
||||
@@ -702,6 +711,7 @@
|
||||
Minimum… Mínimo…
|
||||
Offset… Desplazamiento…
|
||||
Plug-ins Plugins
|
||||
Plugins Plugins
|
||||
Proof Setup Ajuste de prueba
|
||||
Proof Colors Colores de prueba
|
||||
Gamut Warning Advertencia de gama
|
||||
@@ -710,9 +720,9 @@
|
||||
Working Magenta Plate Placa magenta de trabajo
|
||||
Working Yellow Plate Placa amarilla de trabajo
|
||||
Working Black Plate Placa negra de trabajo
|
||||
Working CMY Plate Placa CMY de trabajo
|
||||
Legacy Macintosh RGB RGB heredado de Macintosh
|
||||
Internet Standard RGB RGB estándar de Internet
|
||||
Working CMY Plates Placa CMY de trabajo
|
||||
Legacy Macintosh RGB (Gamma 1.8) RGB heredado de Macintosh (Gamma 1.8)
|
||||
Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB)
|
||||
Monitor RGB RGB del monitor
|
||||
Color Blindness — Protanopia-type Daltonismo — tipo protanopia
|
||||
Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia
|
||||
@@ -804,7 +814,6 @@
|
||||
Consolidate All to Tabs Consolidar todo en pestañas
|
||||
Float in Window Hacer flotante en ventana
|
||||
Float All in Windows Hacer flotantes todas en ventanas
|
||||
New Window for Document Nueva ventana para el documento
|
||||
Workspace Espacio de trabajo
|
||||
Essentials Esenciales
|
||||
Graphic and Web Gráficos y web
|
||||
@@ -1973,6 +1982,11 @@
|
||||
Show canvas dots Mostrar puntos del lienzo
|
||||
Clip thumbnails to layer bounds Recortar miniaturas a los límites de la capa
|
||||
Clip Thumbnails to Layer Bounds Recortar miniaturas a los límites de la capa
|
||||
No Thumbnails Sin miniaturas
|
||||
Small Thumbnails Miniaturas pequeñas
|
||||
Medium Thumbnails Miniaturas medianas
|
||||
Large Thumbnails Miniaturas grandes
|
||||
Clip Thumbnails to Document Bounds Recortar miniaturas a los límites del documento
|
||||
Canvas color Color del lienzo
|
||||
Canvas custom color Color personalizado del lienzo
|
||||
Color picker Selector de color
|
||||
@@ -4477,3 +4491,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded
|
||||
Replace Image… Reemplazar imagen…
|
||||
The frame is locked El marco está bloqueado
|
||||
The frame is unavailable El marco no está disponible
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
Channel Options… Opciones de canal…
|
||||
Specify Channels: Especificar canales:
|
||||
Not enough grayscale documents for this mode. No hay suficientes documentos en escala de grises para este modo.
|
||||
Pick a different document for each channel. Elija un documento distinto para cada canal.
|
||||
A picked document is no longer open. Uno de los documentos elegidos ya no está abierto.
|
||||
A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -87,6 +87,7 @@
|
||||
Right Oikea
|
||||
Open Recent Viimeksi avatut
|
||||
Clear Recent Files Tyhjennä viimeisimmät tiedostot
|
||||
Clear Recent File List Tyhjennä viimeisimmät tiedostot
|
||||
Remove from Recent Poista viimeisimmistä
|
||||
Remove {name} from Recent Poista {name} viimeisimmistä
|
||||
Close Sulje
|
||||
@@ -95,6 +96,10 @@
|
||||
Restore Palauta
|
||||
Close All Sulje kaikki
|
||||
Close Others Sulje muut
|
||||
New Document… Uusi asiakirja…
|
||||
Open Document… Avaa…
|
||||
Consolidate All to Here Kokoa kaikki tähän
|
||||
Move to New Window Siirrä uuteen ikkunaan
|
||||
Save Tallenna
|
||||
Save As… Tallenna nimellä…
|
||||
Save a Copy… Tallenna kopio…
|
||||
@@ -509,7 +514,6 @@
|
||||
Match Location Täsmäytä sijainti
|
||||
Match Rotation Täsmäytä kierto
|
||||
Match All Täsmäytä kaikki
|
||||
New Window for Document Uusi ikkuna asiakirjalle
|
||||
Workspace Työtila
|
||||
Extensions Laajennukset
|
||||
Panels Paneelit
|
||||
@@ -687,6 +691,7 @@
|
||||
Units & Rulers… Yksiköt ja viivaimet…
|
||||
Guides, Grid & Slices… Apuviivat, ruudukko ja viipaleet…
|
||||
Plug-ins… Laajennukset…
|
||||
Plugins… Laajennukset…
|
||||
Technology Previews Teknologian esikatselut
|
||||
Scratch Disks… Työlevyt…
|
||||
Export Preferences… Vientiasetukset…
|
||||
@@ -921,8 +926,8 @@
|
||||
ACES ACES
|
||||
Industry Standard Alan standardi
|
||||
Web Verkko
|
||||
Internet Standard RGB Internetin standardi-RGB
|
||||
Legacy Macintosh RGB Vanha Macintosh RGB
|
||||
Internet Standard RGB (sRGB) Internetin standardi-RGB (sRGB)
|
||||
Legacy Macintosh RGB (Gamma 1.8) Vanha Macintosh RGB (Gamma 1.8)
|
||||
Monitor RGB Näytön RGB
|
||||
Windows LCD Windows LCD
|
||||
Coated CMYK CMYK, päällystetty paperi
|
||||
@@ -1743,6 +1748,7 @@
|
||||
Saved Tallennettu
|
||||
Reveal in Finder Näytä Finderissa
|
||||
Show in Explorer Näytä Resurssienhallinnassa
|
||||
Reveal in Explorer Näytä Resurssienhallinnassa
|
||||
Show in Folder Näytä kansiossa
|
||||
Before (Y) Ennen (Y)
|
||||
Save Settings… Tallenna asetukset…
|
||||
@@ -1922,6 +1928,8 @@
|
||||
Combine Shapes Yhdistä muodot
|
||||
Contract… Supista…
|
||||
Convert to Embedded Muunna upotetuksi
|
||||
Embed Linked Upota linkitetty
|
||||
Embed All Linked Upota kaikki linkitetyt
|
||||
Convert to Layers Muunna tasoiksi
|
||||
Convert to Linked… Muunna linkitetyksi…
|
||||
Count Laskuri
|
||||
@@ -2009,6 +2017,7 @@
|
||||
Pixelate Pikselöinti
|
||||
Place Scale Marker… Sijoita mittakaavamerkki…
|
||||
Plug-ins Laajennukset
|
||||
Plugins Laajennukset
|
||||
Print One Copy Tulosta yksi kopio
|
||||
Print… Tulosta…
|
||||
Range Alue
|
||||
@@ -2053,7 +2062,7 @@
|
||||
Vertical Centers Pystykeskikohdat
|
||||
Video Frames to Layers… Videoruudut tasoiksi…
|
||||
WIA Support… WIA-tuki…
|
||||
Working CMY Plate Työ-CMY-levy
|
||||
Working CMY Plates Työ-CMY-levy
|
||||
|
||||
# tl! literals (third pass)
|
||||
Absolute Absoluuttinen
|
||||
@@ -2339,6 +2348,11 @@
|
||||
Show canvas dots Näytä kankaan pisteet
|
||||
Clip thumbnails to layer bounds Rajaa pikkukuvat tason rajoihin
|
||||
Clip Thumbnails to Layer Bounds Rajaa pikkukuvat tason rajoihin
|
||||
No Thumbnails Ei pikkukuvia
|
||||
Small Thumbnails Pienet pikkukuvat
|
||||
Medium Thumbnails Keskikokoiset pikkukuvat
|
||||
Large Thumbnails Suuret pikkukuvat
|
||||
Clip Thumbnails to Document Bounds Rajaa pikkukuvat asiakirjan rajoihin
|
||||
Canvas color Kankaan väri
|
||||
Canvas custom color Kankaan mukautettu väri
|
||||
Classic Klassinen
|
||||
@@ -4788,3 +4802,11 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen
|
||||
Pause For Tauko
|
||||
seconds sekuntia
|
||||
Create a single history state Luo yksi historiatila
|
||||
|
||||
# Channels panel menu (#11451)
|
||||
Channel Options… Kanavan asetukset…
|
||||
Specify Channels: Määritä kanavat:
|
||||
Not enough grayscale documents for this mode. Harmaasävyasiakirjoja ei ole tarpeeksi tähän tilaan.
|
||||
Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja.
|
||||
A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki.
|
||||
A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user