diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 15a2eb72aa..a0c363732a 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -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) /// 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, budget: usize) -> Surface { resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE) } diff --git a/crates/algo/src/resample/tests.rs b/crates/algo/src/resample/tests.rs index 3c80881675..2407457a2d 100644 --- a/crates/algo/src/resample/tests.rs +++ b/crates/algo/src/resample/tests.rs @@ -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] { diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 8493f2c1e8..f740687c1a 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -778,6 +778,8 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +#[cfg(test)] +mod ellipse_aa_edge_tests; mod inverse; mod lasso; mod marquee; diff --git a/crates/algo/src/selection/ellipse_aa_edge_tests.rs b/crates/algo/src/selection/ellipse_aa_edge_tests.rs new file mode 100644 index 0000000000..7b3e08cfa0 --- /dev/null +++ b/crates/algo/src/selection/ellipse_aa_edge_tests.rs @@ -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::(); + (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}"); +} diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index 7afd788fdc..6afff4ab50 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -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 { diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 9c3e7f9fbc..f6e3fd4a12 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -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(()) })?; } diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 252a687d7a..1750623eae 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -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.") { diff --git a/crates/automation/tests/save_snapshot.rs b/crates/automation/tests/save_snapshot.rs new file mode 100644 index 0000000000..95c922c4a3 --- /dev/null +++ b/crates/automation/tests/save_snapshot.rs @@ -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(); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a21c38f06a..6d6700dc91 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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). diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index 4608186f01..0bf2a335ad 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -188,6 +188,8 @@ pub(crate) fn compound(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(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 { diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index f9e31e4cd0..470971831a 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -289,10 +289,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { }; 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 { 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 { let cmd = "layer.new.artboardFromGroup"; @@ -705,7 +727,7 @@ pub fn specs() -> Vec { "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 { ] } +#[cfg(test)] +#[path = "artboard_cmds/first_board_background_tests.rs"] +mod first_board_background_tests; #[cfg(test)] #[path = "artboard_cmds/tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs b/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs new file mode 100644 index 0000000000..5d1f50206f --- /dev/null +++ b/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs @@ -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 { + s.active().unwrap().history.entries() +} + +fn draw(s: &mut Session, take: serde_json::Value) -> Result { + 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")); +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 54d8b9995d..a51022dceb 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -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)] diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 922c6125a6..8757b6ba04 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -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 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 { + 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 { + 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 = 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 { + 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) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_stack.rs b/crates/engine/src/cmd/atn_cmds/steps_stack.rs new file mode 100644 index 0000000000..9fcb9a6f9b --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_stack.rs @@ -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::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 { + 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 { + 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() +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_video.rs b/crates/engine/src/cmd/atn_cmds/steps_video.rs new file mode 100644 index 0000000000..af3158a978 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_video.rs @@ -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 { + 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)) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b7c3764346..a65404324f 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -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"); diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 093c683fa1..a860b50db5 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -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()) } } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index b7f2074b98..3a56aefc3b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -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"); +} diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index 156095288f..7d820f0620 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -407,7 +407,7 @@ pub fn specs() -> Vec { 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 { id: PERSPECTIVE_SMART, label: "Perspective Warp", - menu: &["Layer", "Smart Objects"], + menu: &[], shortcut: None, params: Q, enabled: smart_layer, diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 88bbafe878..e6f7e77b9d 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -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")?; diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index bb93f233cb..0700fb30c8 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -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, } /// 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 { 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 { 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 { 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, diff --git a/crates/engine/src/cmd/history_options_cmds.rs b/crates/engine/src/cmd/history_options_cmds.rs index 17341b5ae1..c2f814d008 100644 --- a/crates/engine/src/cmd/history_options_cmds.rs +++ b/crates/engine/src/cmd/history_options_cmds.rs @@ -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) -> 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 { + 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 { + #[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 { let given: Map = match p { Value::Null => Map::new(), @@ -39,7 +117,7 @@ fn options(s: &mut Session, p: &Value) -> Result { // 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 { 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 { 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 = 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 { + s.active().unwrap().snapshots.iter().map(|snap| snap.name.clone()).collect() + } + + /// PNG bytes of a small image, to open. + fn png() -> Vec { + 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::).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. diff --git a/crates/engine/src/cmd/history_source_cmds.rs b/crates/engine/src/cmd/history_source_cmds.rs index c4c0f5e225..c42b69b3d8 100644 --- a/crates/engine/src/cmd/history_source_cmds.rs +++ b/crates/engine/src/cmd/history_source_cmds.rs @@ -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); } } diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 6de4bf6f70..60c7587207 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -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 { 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 { 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 { 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 { 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) -> EngineError { @@ -714,6 +798,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result // 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 // 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 { "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 ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fc4ce0bcf9..96e0637994 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index f85423c048..4ca3074b13 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -158,7 +158,7 @@ pub fn specs() -> Vec { "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 diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 3043f080e6..ea6504f7a8 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { } 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; diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 4d3c97b02b..8f190f445c 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -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 diff --git a/crates/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 55e2ec10d9..9fd0d65356 100644 --- a/crates/engine/src/cmd/select_extra_cmds.rs +++ b/crates/engine/src/cmd/select_extra_cmds.rs @@ -163,6 +163,11 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result { /// scale/rotate/move, refused when the result would not fit the transform budget. fn transform_selection(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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(); diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 927beba121..13737e70bd 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -621,7 +621,9 @@ fn lasso(s: &mut Session, p: &Value) -> Result { 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 { "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 ), diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index fd9234fc3e..a79927652c 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -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 { + 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 { + 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 = ids.iter().map(|id| smart(doc, *id).map(|sm| sm.contents_id)).collect::>()?; + 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::>()})) +} 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 { 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…", diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 211c64668f..f0d7150567 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -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) -> 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 { + // 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 { // 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 { 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, diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index 10aecc0b4b..124e69198d 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -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"); +} diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index c4cb0d9471..cd18abb315 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -315,13 +315,13 @@ pub fn specs() -> Vec { "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!( diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index d5540c1f79..3b1a631c81 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -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) -> 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 { 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 { fn insert_blank(s: &mut Session, _p: &Value) -> Result { 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 { fn duplicate_frame(s: &mut Session, _p: &Value) -> Result { 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); diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index f2acb0bd0f..d7feacb06a 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs index c2f434fb1e..4b94af82a9 100644 --- a/crates/engine/src/cmd/warp_cmds.rs +++ b/crates/engine/src/cmd/warp_cmds.rs @@ -664,7 +664,7 @@ pub fn specs() -> Vec { CommandSpec { id: "layer.smartObjects.warp", label: "Warp", - menu: &["Layer", "Smart Objects"], + menu: &[], shortcut: None, params: P, enabled: active_smart, diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs index 339da83ce9..f5da0de07b 100644 --- a/crates/engine/src/crop_fill.rs +++ b/crates/engine/src/crop_fill.rs @@ -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, + pub held: Option>, } 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) -> 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, 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, cov: &Coverage, delete: bool) -> Result { + 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, cov: &Coverage, own_pixels: bool) -> Result { 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, 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)> = pieces - .par_iter() - .enumerate() - .map(|(k, bounds)| -> Result<(Rect, Vec)> { - 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)> { + 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::>()?; + } + Ok((*bounds, out)) + }; + let filled: Vec<(Rect, Vec)> = if own_pixels { + // A fallback window can span the canvas: keep only one added-target window live. + pieces.iter().enumerate().map(fill_piece).collect::>()? + } else { + pieces.par_iter().enumerate().map(fill_piece).collect::>()? + }; 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 { + 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::())); + 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) -> Option { + 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"); } } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index e10f5be984..06123cbab1 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -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, /// 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, /// 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, @@ -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(), } diff --git a/crates/engine/src/embed_all_linked_tests.rs b/crates/engine/src/embed_all_linked_tests.rs new file mode 100644 index 0000000000..70bed5c0c1 --- /dev/null +++ b/crates/engine/src/embed_all_linked_tests.rs @@ -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) { + 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> = (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(); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e74b784519..468efec851 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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)] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c0d3fab250..5b666078c6 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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, + } } } diff --git a/crates/engine/src/prefs/cmds.rs b/crates/engine/src/prefs/cmds.rs index 2c15505fab..fa695e1e92 100644 --- a/crates/engine/src/prefs/cmds.rs +++ b/crates/engine/src/prefs/cmds.rs @@ -177,7 +177,7 @@ pub fn specs() -> Vec { 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…"), diff --git a/crates/engine/src/prefs/shortcuts.rs b/crates/engine/src/prefs/shortcuts.rs index 337c8e3754..f2a69c30ce 100644 --- a/crates/engine/src/prefs/shortcuts.rs +++ b/crates/engine/src/prefs/shortcuts.rs @@ -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"), ]; diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 1baa1d9994..175d33f26a 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -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"), diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index 6c7afc15d9..c7e8bcabf0 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -305,8 +305,8 @@ pub fn specs() -> Vec { 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 { diff --git a/crates/engine/src/second_keys_zoom_actions_tests.rs b/crates/engine/src/second_keys_zoom_actions_tests.rs new file mode 100644 index 0000000000..92d3b820cf --- /dev/null +++ b/crates/engine/src/second_keys_zoom_actions_tests.rs @@ -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::>(), ["Cmd+Alt+0"]); + assert_eq!(default_alternates("window.panel.actions").collect::>(), ["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"]); +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 3e6bc4abc7..7cef2be12e 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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); diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 3b52c03a03..f2af5eb5e9 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -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")), diff --git a/crates/engine/tests/it/atn_perspective_warp.rs b/crates/engine/tests/it/atn_perspective_warp.rs new file mode 100644 index 0000000000..2967285842 --- /dev/null +++ b/crates/engine/tests/it/atn_perspective_warp.rs @@ -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()); +} diff --git a/crates/engine/tests/it/atn_stack_modes.rs b/crates/engine/tests/it/atn_stack_modes.rs new file mode 100644 index 0000000000..2de2d887df --- /dev/null +++ b/crates/engine/tests/it/atn_stack_modes.rs @@ -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) -> 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()); + } + } +} diff --git a/crates/engine/tests/it/atn_video_frames.rs b/crates/engine/tests/it/atn_video_frames.rs new file mode 100644 index 0000000000..43c9261ae1 --- /dev/null +++ b/crates/engine/tests/it/atn_video_frames.rs @@ -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 { + 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) -> 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> { + 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]| 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()), + } + } + } +} diff --git a/crates/engine/tests/it/crop_content_aware_layers.rs b/crates/engine/tests/it/crop_content_aware_layers.rs new file mode 100644 index 0000000000..bd3ee0db2c --- /dev/null +++ b/crates/engine/tests/it/crop_content_aware_layers.rs @@ -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); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index c65e58ddca..7cad24d146 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/crates/engine/tests/it/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs index c32041581f..1016aac628 100644 --- a/crates/engine/tests/it/svg_smart_object.rs +++ b/crates/engine/tests/it/svg_smart_object.rs @@ -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!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let hostile = format!("]>&a;"); + 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] diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index ba4c368c5d..dcd86482db 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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"] } diff --git a/crates/io/src/perspective_warp_map.rs b/crates/io/src/perspective_warp_map.rs index 654de23b32..cb5815bb81 100644 --- a/crates/io/src/perspective_warp_map.rs +++ b/crates/io/src/perspective_warp_map.rs @@ -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> { .map(|q| { let Value::Descriptor(q) = q else { return None }; let Some(Value::List(ix)) = q.get("indices") else { return None }; - let ix: Vec = ix - .iter() - .map(|i| match i { - Value::Integer(i) => usize::try_from(*i).ok(), - _ => None, - }) - .collect::>()?; - 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> { +/// 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, 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 = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; + let indices = quads(f)?; + let planes: Vec = indices.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; PerspectiveMap::new(&planes)?; - Some(planes) + Some((planes, indices)) +} + +fn planes_of(f: &Descriptor) -> Option> { + indexed_geometry(f).map(|(planes, _)| planes) } /// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item. @@ -137,7 +137,8 @@ fn template(params: &J) -> Result, String> { /// The planes to write: as rendered, so a `straighten` is baked in. fn export_planes(params: &J) -> Result, 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 = @@ -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 { diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index cacce2b7cb..3b19606c94 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -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; diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 2d33f69fb1..ba8900fb87 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -281,15 +281,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc) -> 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>>, usize); + static CMAPS: OnceLock> = 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 { + 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, 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 { /// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one). fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Result { 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` 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 { 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 { + 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"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"`, 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"") + } else if rest.starts_with(b" 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. diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index e1d2a1a890..15b6379359 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -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 { + 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 = "I\u{2500}I"; + 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::>() + }; + 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 { + 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> = 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"I\xE2\x94\x80I"; + 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"))] diff --git a/crates/io/src/xml.rs b/crates/io/src/xml.rs index 13e02004d1..423c8c8555 100644 --- a/crates/io/src/xml.rs +++ b/crates/io/src/xml.rs @@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result 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()) diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 0cd3c832b3..3f0ba53462 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -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!("{body}"); + let cases = [ + ("entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("single-quoted entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("quote in a comment", with_dtd("", &format!("{}'", deep("", "")))), + ("quote in an element declaration", with_dtd("", &format!("{}'", deep("", "")))), + ( + "entity between quoted attribute lists", + with_dtd(&format!("", deep("", "")), "&a;"), + ), + ("entity between quoted notations", with_dtd(&format!("", deep("", "")), "&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( + " ?> ]>\ + &c;", + ); + 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] diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index df4bc77986..12f3274046 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index e106a2ecfb..26ac96f722 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -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) { diff --git a/crates/ui-egui/src/artboard_add_mode_tests.rs b/crates/ui-egui/src/artboard_add_mode_tests.rs new file mode 100644 index 0000000000..3b98287d41 --- /dev/null +++ b/crates/ui-egui/src/artboard_add_mode_tests.rs @@ -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 { + app.session.active().unwrap().doc.artboards().iter().map(|b| b.2.rect).collect() +} + +fn last_step(app: &PhotocraftApp) -> Option { + 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); +} diff --git a/crates/ui-egui/src/artboard_first_board_tests.rs b/crates/ui-egui/src/artboard_first_board_tests.rs new file mode 100644 index 0000000000..7db69e7b32 --- /dev/null +++ b/crates/ui-egui/src/artboard_first_board_tests.rs @@ -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")); +} diff --git a/crates/ui-egui/src/blend_dropdown_groups_tests.rs b/crates/ui-egui/src/blend_dropdown_groups_tests.rs new file mode 100644 index 0000000000..4b5bb069a0 --- /dev/null +++ b/crates/ui-egui/src/blend_dropdown_groups_tests.rs @@ -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 { + 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 = 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 = 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"); + } +} diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index 3523267b10..431ad7aea2 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -72,7 +72,7 @@ pub(crate) fn choices() -> Vec<((Option, bool), &'static str)> { fn blend_items(behind: bool, clear: bool) -> Vec { 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)); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3cdd87d26b..c0b4f5d883 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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) { diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 5cfa676b66..f633dc91e0 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index ac3da2eacb..aae9dbdb86 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -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, + /// 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, pub(super) pen_owner: Option, + /// 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); } diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index ae89b9063f..04ad55a3a7 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -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) { // 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) -> 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})); } } diff --git a/crates/ui-egui/src/canvas/move_ui.rs b/crates/ui-egui/src/canvas/move_ui.rs index 2f702575aa..2a70bd6a7a 100644 --- a/crates/ui-egui/src/canvas/move_ui.rs +++ b/crates/ui-egui/src/canvas/move_ui.rs @@ -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 diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index 67577e2a27..b3e37d1f18 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -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; diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 9184abe989..989af47e1c 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -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() { diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index 17ba7ca349..b42264a188 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 3dffe11cc4..7389104f6c 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -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 }) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 7882fc43c9..661da5d277 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -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::>(), ["file.close", "file.closeOthers", "file.closeAll", "file.revealInFinder"]); - assert_eq!(items[0].2, json!({"document": 2})); + assert_eq!( + items.iter().map(|(_, id, _, _)| *id).collect::>(), + [ + "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); diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 6db5aace3e..08aff7dd70 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -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 { // 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`). diff --git a/crates/ui-egui/src/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 53df5d373f..5b3b952f65 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ddd3b7347..0cfeb7fdc1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { let mut outcome: Option = 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 /// 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 { 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; diff --git a/crates/ui-egui/src/dialogs/channel_options_ui.rs b/crates/ui-egui/src/dialogs/channel_options_ui.rs index 3a3453e840..834ef82022 100644 --- a/crates/ui-egui/src/dialogs/channel_options_ui.rs +++ b/crates/ui-egui/src/dialogs/channel_options_ui.rs @@ -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) -> bool { fields.contains_key(MARK) @@ -75,6 +80,28 @@ pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option { 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> { + 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 { + 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) -> Value { @@ -92,7 +119,7 @@ pub fn params(f: &Map) -> Value { } pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - 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) { 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")); } }); diff --git a/crates/ui-egui/src/dialogs/history_options_ui.rs b/crates/ui-egui/src/dialogs/history_options_ui.rs index 2d4756543b..0ff35dbc0e 100644 --- a/crates/ui-egui/src/dialogs/history_options_ui.rs +++ b/crates/ui-egui/src/dialogs/history_options_ui.rs @@ -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 = 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"); } diff --git a/crates/ui-egui/src/dialogs/merge_channels_ui.rs b/crates/ui-egui/src/dialogs/merge_channels_ui.rs new file mode 100644 index 0000000000..c7d97512a0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/merge_channels_ui.rs @@ -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) -> 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) -> &str { + f.get("mode").and_then(Value::as_str).unwrap_or("rgb") +} + +fn sources_field(f: &Map) -> 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) -> Vec { + let all: Vec = 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> { + app.session.active()?; + let sources = sources(app); + let picks: Vec = 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 { + 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) -> 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) -> 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) -> Result, &'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::, _>>()?; + 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) -> Result { + 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) -> Result { + 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) { + 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 = 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) -> Vec { + 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::>(), 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 = (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 = (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)); + } + } +} diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index ebef97f740..a37d94d5a8 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -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; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs new file mode 100644 index 0000000000..de138c5801 --- /dev/null +++ b/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/dialogs/sizing/tests.rs b/crates/ui-egui/src/dialogs/sizing/tests.rs index a7e7c1dd20..7c26de6c81 100644 --- a/crates/ui-egui/src/dialogs/sizing/tests.rs +++ b/crates/ui-egui/src/dialogs/sizing/tests.rs @@ -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()); diff --git a/crates/ui-egui/src/doc_tab_menu_tests.rs b/crates/ui-egui/src/doc_tab_menu_tests.rs new file mode 100644 index 0000000000..090455bd59 --- /dev/null +++ b/crates/ui-egui/src/doc_tab_menu_tests.rs @@ -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>>; + +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 { + 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 = 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 = rows(&h).into_iter().map(|r| r.1).collect(); + let gaps: Vec = 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 { + 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}))); +} diff --git a/crates/ui-egui/src/export_quality_tests.rs b/crates/ui-egui/src/export_quality_tests.rs new file mode 100644 index 0000000000..1f1bfd88ae --- /dev/null +++ b/crates/ui-egui/src/export_quality_tests.rs @@ -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 { + 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) -> Vec { + 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 = + 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 { + 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"); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 69ee8346ab..80c41fe82b 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -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. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 5d16b97df2..6fc8c62e6a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e11ad45163..a23681f867 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dadc00b8aa..89deab4287 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 801c7b38f8..39147f17c9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4ef92c6162..d2f684e3ae 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 117026a6d9..8f5c57e3bd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 53086c2582..d89ea90673 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1a937b8fb9..050f58d308 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -171,6 +171,7 @@ Right Droite Open Recent Ouvrir un fichier récent Clear Recent Files Effacer la liste des fichiers récents + Clear Recent File List Effacer la liste des fichiers récents Remove from Recent Retirer des fichiers récents Remove {name} from Recent Retirer {name} des fichiers récents Close Fermer @@ -179,6 +180,10 @@ Restore Restaurer Close All Tout fermer Close Others Fermer les autres + New Document… Nouveau document… + Open Document… Ouvrir… + Consolidate All to Here Tout regrouper ici + Move to New Window Déplacer vers une nouvelle fenêtre Save Enregistrer Save As… Enregistrer sous… Save a Copy… Enregistrer une copie… @@ -231,6 +236,7 @@ Create Smart Object after Loading Layers Créer un objet intelligent après le chargement des calques Reveal in Finder Afficher dans le Finder Show in Explorer Afficher dans l'Explorateur + Reveal in Explorer Afficher dans l'Explorateur Show in Folder Afficher dans le dossier File Info… Informations sur le fichier… Print… Imprimer… @@ -317,6 +323,7 @@ Units & Rulers… Unités et règles… Guides, Grid & Slices… Repères, grille et tranches… Plug-ins… Extensions… + Plugins… Extensions… Type… Texte… Enhanced Controls… Commandes avancées… Camera Raw… Camera Raw… @@ -466,6 +473,8 @@ Smart Object Objet intelligent Convert to Smart Object Convertir en objet intelligent Convert to Embedded Convertir en objet incorporé + Embed Linked Incorporer le lié + Embed All Linked Incorporer tous les liés Convert to Layers Convertir en calques Convert to Linked… Convertir en objet lié… Edit Contents Modifier le contenu @@ -718,6 +727,7 @@ Minimum… Minimum… Offset… Décalage… Plug-ins Extensions + Plugins Extensions Proof Setup Configuration d'épreuve Proof Colors Couleurs d'épreuve Gamut Warning Alerte de gamut @@ -726,9 +736,9 @@ Working Magenta Plate Plaque magenta de travail Working Yellow Plate Plaque jaune de travail Working Black Plate Plaque noire de travail - Working CMY Plate Plaque CMJ de travail - Legacy Macintosh RGB RVB Macintosh (ancien) - Internet Standard RGB RVB standard d'Internet + Working CMY Plates Plaque CMJ de travail + Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh (ancien) (Gamma 1.8) + Internet Standard RGB (sRGB) RVB standard d'Internet (sRGB) Monitor RGB RVB du moniteur Color Blindness — Protanopia-type Daltonisme — type protanopie Color Blindness — Deuteranopia-type Daltonisme — type deutéranopie @@ -820,7 +830,6 @@ Consolidate All to Tabs Tout regrouper dans des onglets Float in Window Flottante dans une fenêtre Float All in Windows Toutes flottantes dans des fenêtres - New Window for Document Nouvelle fenêtre pour le document Workspace Espace de travail Essentials Essentiel Graphic and Web Graphisme et Web @@ -1979,6 +1988,11 @@ Show canvas dots Afficher les points du canevas Clip thumbnails to layer bounds Rogner les vignettes aux limites du calque Clip Thumbnails to Layer Bounds Rogner les vignettes aux limites du calque + No Thumbnails Aucune vignette + Small Thumbnails Petites vignettes + Medium Thumbnails Vignettes moyennes + Large Thumbnails Grandes vignettes + Clip Thumbnails to Document Bounds Rogner les vignettes aux limites du document Canvas color Couleur du canevas Canvas custom color Couleur personnalisée du canevas Color picker Sélecteur de couleurs @@ -4600,3 +4614,11 @@ shape Appearance Apparence Replace Image… Remplacer l’image… The frame is locked Le cadre est verrouillé The frame is unavailable Le cadre n’est pas disponible + +# Channels panel menu (#11451) + Channel Options… Options de canal… + Specify Channels: Indiquer les canaux : + Not enough grayscale documents for this mode. Pas assez de documents en niveaux de gris pour ce mode. + Pick a different document for each channel. Choisissez un document différent pour chaque canal. + A picked document is no longer open. Un des documents choisis n'est plus ouvert. + A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b1fc4631e0..aa79f03063 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -65,6 +65,10 @@ Close बंद करें Close All सभी बंद करें Close Others अन्य सभी बंद करें + New Document… New Document… + Open Document… खोलें… + Consolidate All to Here सभी को यहाँ समेकित करें + Move to New Window नई विंडो में ले जाएँ Save सहेजें Save As… इस रूप में सहेजें… Save a Copy… एक प्रति सहेजें… @@ -630,6 +634,8 @@ cameraRaw Yellow पीला @plural {n} action file loaded|{n} action files loaded {n} एक्शन फ़ाइल लोड हुई|{n} एक्शन फ़ाइलें लोड हुईं {set} from {file} replaced the one from {earlier} {file} के सेट {set} ने {earlier} वाले सेट की जगह ले ली @plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} फ़ाइल लोड नहीं हो सकी: {errors}|{n} फ़ाइलें लोड नहीं हो सकीं: {errors} + Embed Linked लिंक किए गए को एम्बेड करें + Embed All Linked सभी लिंक किए गए को एम्बेड करें Toggle reference point संदर्भ बिंदु दिखाएँ/छिपाएँ # Action Stop message (#8386) @@ -645,3 +651,18 @@ cameraRaw Yellow पीला 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. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 14606a1e70..b54afb204c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -158,6 +158,10 @@ Clipboard Vágólap Close All Összes bezárása Close Others Többi bezárása + New Document… Új dokumentum… + Open Document… Megnyitás… + Consolidate All to Here Összes ide + Move to New Window Áthelyezés új ablakba Color Balance… Színegyensúly… Color Lookup Tables… Színleképezési táblák… Color Lookup… Színleképezés… @@ -257,6 +261,7 @@ Place Linked… Csatolt elhelyezése… Place Scale Marker… Méretarány-jelölő elhelyezése… Plug-ins… Bővítmények… + Plugins… Bővítmények… Posterize… Poszterizálás… Preferences Beállítások Preset Manager… Előbeállítás-kezelő… @@ -342,6 +347,8 @@ Combine Shapes Alakzatok kombinálása Contract… Szűkítés… Convert to Embedded Átalakítás beágyazottá + Embed Linked Csatolt beágyazása + Embed All Linked Összes csatolt beágyazása Convert to Layers Átalakítás rétegekké Convert to Linked… Átalakítás csatolttá… Convert to Smart Object Átalakítás intelligens objektummá @@ -550,6 +557,7 @@ Clear Canvas Guides Vászon-segédvonalak törlése Clear Guides Segédvonalak törlése Clear Recent Files Legutóbbi fájlok listájának törlése + Clear Recent File List Legutóbbi fájlok listájának törlése Clear Selected Artboard Guides Kijelölt rajztábla segédvonalainak törlése Clear Slices Szeletek törlése Clipping Mask Vágómaszk @@ -681,7 +689,7 @@ Info Információ Install Plug-in Bővítmény telepítése Install Plug-in… Bővítmény telepítése… - Internet Standard RGB Internetes szabványos RGB + Internet Standard RGB (sRGB) Internetes szabványos RGB (sRGB) Inverse Megfordítás Iris Blur… Íriszelmosás… Join the ArtCraft Discord Csatlakozás az ArtCraft Discordhoz @@ -698,7 +706,7 @@ Layer From Background… Réteg a háttérből… Layers Rétegek Layers to Files Rétegek fájlokba - Legacy Macintosh RGB Régi Macintosh RGB + Legacy Macintosh RGB (Gamma 1.8) Régi Macintosh RGB (Gamma 1.8) Lens Blur… Objektívelmosás… Lens Flare… Lencsecsillanás… Light gray Világosszürke @@ -746,7 +754,6 @@ New Type Layer Új szövegréteg New Video Layer Új videoréteg New Window Új ablak - New Window for Document Új ablak a dokumentumhoz New Workspace… Új munkaterület… Next Appearance Mode Következő megjelenési mód Next Document Következő dokumentum @@ -791,6 +798,7 @@ Pixel Grid Pixelrács Pixelate Pixelesítés Plug-ins Bővítmények + Plugins Bővítmények Pointillize… Pontozás… Polar Coordinates… Polárkoordináták… Preferences… Beállítások… @@ -854,6 +862,7 @@ Show All Összes megjelenítése Show Extras Options… Segédelemek beállításai… Show in Explorer Megjelenítés az Intézőben + Reveal in Explorer Megjelenítés az Intézőben Show in Folder Megjelenítés mappában Show menu colors Menüszínek megjelenítése Show/Hide All Panels Összes panel megjelenítése/elrejtése @@ -919,7 +928,7 @@ Windows LCD Windows LCD Wind… Szél… Working Black Plate Fekete munkalemez - Working CMY Plate CMY munkalemezek + Working CMY Plates CMY munkalemezek Working CMYK Munka-CMYK Working Cyan Plate Cián munkalemez Working Magenta Plate Magenta munkalemez @@ -3630,6 +3639,11 @@ cameraRaw Yellow Sárga Show canvas dots Vászonpontok megjelenítése Clip thumbnails to layer bounds Bélyegképek levágása a réteg határaira Clip Thumbnails to Layer Bounds Bélyegképek levágása a réteg határaira + No Thumbnails Nincs bélyegkép + Small Thumbnails Kis bélyegképek + Medium Thumbnails Közepes bélyegképek + Large Thumbnails Nagy bélyegképek + Clip Thumbnails to Document Bounds Bélyegképek levágása a dokumentum határaira Show channels in color Csatornák megjelenítése színesen Show crosshair in brush tip Szálkereszt az ecsethegyben Show extension panels Bővítménypanelek megjelenítése @@ -4484,3 +4498,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Kép cseréje… The frame is locked A keret zárolva van The frame is unavailable A keret nem érhető el + +# Channels panel menu (#11451) + Channel Options… Csatornabeállítások… + Specify Channels: Csatornák megadása: + Not enough grayscale documents for this mode. Nincs elég szürkeárnyalatos dokumentum ehhez a módhoz. + Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. + A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. + A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bc6766f662..b2f51040e8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -164,6 +164,7 @@ Right Kanan Open Recent Terakhir Dibuka Clear Recent Files Hapus Daftar Berkas Terakhir + Clear Recent File List Hapus Daftar Berkas Terakhir Remove from Recent Hapus dari Daftar Terakhir Remove {name} from Recent Hapus {name} dari Daftar Terakhir Close Tutup @@ -172,6 +173,10 @@ Restore Pulihkan Close All Tutup Semua Close Others Tutup Lainnya + New Document… Dokumen Baru… + Open Document… Buka… + Consolidate All to Here Gabungkan Semua ke Sini + Move to New Window Pindahkan ke Jendela Baru Save Simpan Save As… Simpan Sebagai… Save a Copy… Simpan Salinan… @@ -224,6 +229,7 @@ Create Smart Object after Loading Layers Buat Smart Object setelah Memuat Layer Reveal in Finder Tampilkan di Finder Show in Explorer Tampilkan di Explorer + Reveal in Explorer Tampilkan di Explorer Show in Folder Tampilkan di Folder File Info… Info Berkas… Print… Cetak… @@ -310,6 +316,7 @@ Units & Rulers… Satuan & Penggaris… Guides, Grid & Slices… Panduan, Grid & Slice… Plug-ins… Plug-in… + Plugins… Plug-in… Type… Teks… Enhanced Controls… Kontrol Lanjutan… Camera Raw… Camera Raw… @@ -459,6 +466,8 @@ Smart Object Smart Object Convert to Smart Object Konversi ke Smart Object Convert to Embedded Konversi ke Tertanam + Embed Linked Tanamkan yang Tertaut + Embed All Linked Tanamkan Semua yang Tertaut Convert to Layers Konversi ke Layer Convert to Linked… Konversi ke Tertaut… Edit Contents Edit Isi @@ -526,7 +535,9 @@ Unite Shapes Satukan Bentuk Subtract Front Shape Kurangi Bentuk Depan Intersect Shape Areas Irisan Area Bentuk + Unite Shapes at Overlap Irisan Area Bentuk Exclude Overlapping Shapes Kecualikan Bentuk yang Tumpang Tindih + Subtract Shapes at Overlap Kecualikan Bentuk yang Tumpang Tindih Merge Shape Components Gabungkan Komponen Bentuk Path operations Operasi path Align Rata @@ -711,6 +722,7 @@ Minimum… Minimum… Offset… Offset… Plug-ins Plug-in + Plugins Plug-in Proof Setup Pengaturan Proof Proof Colors Warna Proof Gamut Warning Peringatan Gamut @@ -719,9 +731,9 @@ Working Magenta Plate Pelat Magenta Kerja Working Yellow Plate Pelat Kuning Kerja Working Black Plate Pelat Hitam Kerja - Working CMY Plate Pelat CMY Kerja - Legacy Macintosh RGB RGB Macintosh Lawas - Internet Standard RGB RGB Standar Internet + Working CMY Plates Pelat CMY Kerja + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh Lawas (Gamma 1.8) + Internet Standard RGB (sRGB) RGB Standar Internet (sRGB) Monitor RGB RGB Monitor Color Blindness — Protanopia-type Buta Warna — Tipe Protanopia Color Blindness — Deuteranopia-type Buta Warna — Tipe Deuteranopia @@ -813,7 +825,7 @@ Consolidate All to Tabs Gabungkan Semua ke Tab Float in Window Mengambang di Jendela Float All in Windows Mengambangkan Semua di Jendela - New Window for Document Jendela Baru untuk Dokumen + New Window Jendela Baru untuk Dokumen Workspace Ruang Kerja Essentials Esensial Graphic and Web Grafis dan Web @@ -1987,6 +1999,11 @@ Show canvas dots Tampilkan titik kanvas Clip thumbnails to layer bounds Pangkas thumbnail ke batas layer Clip Thumbnails to Layer Bounds Pangkas Thumbnail ke Batas Layer + No Thumbnails Tanpa Thumbnail + Small Thumbnails Thumbnail Kecil + Medium Thumbnails Thumbnail Sedang + Large Thumbnails Thumbnail Besar + Clip Thumbnails to Document Bounds Pangkas Thumbnail ke Batas Dokumen Canvas color Warna kanvas Canvas custom color Warna kustom kanvas Color picker Pemilih warna @@ -3590,3 +3607,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is locked Bingkai terkunci The frame is unavailable Bingkai tidak tersedia Frame Bingkai + +# Channels panel menu (#11451) + New Channel… Channel Baru… + Duplicate Channel Duplikat Channel + Delete Channel Hapus Channel + New Spot Channel… Spot Channel Baru… + Merge Spot Channel Gabungkan Spot Channel + Channel Options… Opsi Channel… + Split Channels Pisahkan Channel + Merge Channels… Gabungkan Channel… + Specify Channels: Tentukan Channel: + Not enough grayscale documents for this mode. Dokumen grayscale tidak cukup untuk mode ini. + Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. + A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. + A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 312d902b87..a42efb170f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -186,6 +186,7 @@ Right Destra Open Recent Apri recenti Clear Recent Files Cancella file recenti + Clear Recent File List Cancella file recenti Remove from Recent Rimuovi dai recenti Remove {name} from Recent Rimuovi {name} dai recenti Close Chiudi @@ -194,6 +195,10 @@ Restore Ripristina Close All Chiudi tutto Close Others Chiudi altri + New Document… Nuovo documento… + Open Document… Apri… + Consolidate All to Here Raggruppa tutto qui + Move to New Window Sposta in una nuova finestra Save Salva Save As… Salva con nome… Save a Copy… Salva una copia… @@ -245,6 +250,7 @@ Create Smart Object after Loading Layers Crea oggetto avanzato dopo il caricamento dei livelli Reveal in Finder Mostra nel Finder Show in Explorer Mostra in Esplora file + Reveal in Explorer Mostra in Esplora file Show in Folder Mostra nella cartella File Info… Info file… Print… Stampa… @@ -333,6 +339,7 @@ Units & Rulers… Unità e righelli… Guides, Grid & Slices… Guide, griglia e sezioni… Plug-ins… Plug-in… + Plugins… Plug-in… Type… Testo… Enhanced Controls… Controlli avanzati… Camera Raw… Camera Raw… @@ -490,6 +497,8 @@ Smart Object Oggetto avanzato Convert to Smart Object Converti in oggetto avanzato Convert to Embedded Converti in incorporato + Embed Linked Incorpora collegato + Embed All Linked Incorpora tutti i collegati Convert to Linked… Converti in oggetto collegato… Edit Contents Modifica contenuto Replace Contents… Sostituisci contenuto… @@ -745,6 +754,7 @@ Minimum… Minimo… Offset… Scostamento… Plug-ins Plug-in + Plugins Plug-in Proof Setup Configurazione prova Proof Colors Colori di prova Gamut Warning Avviso gamma @@ -753,9 +763,9 @@ Working Magenta Plate Lastra magenta di lavoro Working Yellow Plate Lastra gialla di lavoro Working Black Plate Lastra nera di lavoro - Working CMY Plate Lastra CMY di lavoro - Legacy Macintosh RGB RGB legacy Macintosh - Internet Standard RGB RGB standard Internet + Working CMY Plates Lastra CMY di lavoro + Legacy Macintosh RGB (Gamma 1.8) RGB legacy Macintosh (Gamma 1.8) + Internet Standard RGB (sRGB) RGB standard Internet (sRGB) Monitor RGB RGB monitor Color Blindness — Protanopia-type Daltonismo — tipo protanopia Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia @@ -847,7 +857,6 @@ Consolidate All to Tabs Raggruppa tutto in schede Float in Window Flottante in finestra Float All in Windows Flottante tutto in finestre - New Window for Document Nuova finestra per documento Workspace Area di lavoro Essentials Essenziali Graphic and Web Grafica e web @@ -2018,6 +2027,11 @@ Show canvas dots Mostra punti del quadro Clip thumbnails to layer bounds Ritaglia le miniature ai limiti del livello Clip Thumbnails to Layer Bounds Ritaglia le miniature ai limiti del livello + No Thumbnails Nessuna miniatura + Small Thumbnails Miniature piccole + Medium Thumbnails Miniature medie + Large Thumbnails Miniature grandi + Clip Thumbnails to Document Bounds Ritaglia le miniature ai limiti del documento Canvas color Colore quadro Canvas custom color Colore personalizzato quadro Color picker Selettore colore @@ -4676,3 +4690,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Sostituisci immagine… The frame is locked La cornice è bloccata The frame is unavailable La cornice non è disponibile + +# Channels panel menu (#11451) + Channel Options… Opzioni canale… + Specify Channels: Specifica canali: + Not enough grayscale documents for this mode. Documenti in scala di grigio insufficienti per questo metodo. + Pick a different document for each channel. Scegli un documento diverso per ogni canale. + A picked document is no longer open. Uno dei documenti scelti non è più aperto. + A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e80417f2be..919d1f0977 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -195,6 +195,7 @@ Right 右 Open Recent 最近使ったファイルを開く Clear Recent Files 最近使ったファイルを消去 + Clear Recent File List 最近使ったファイルを消去 Remove from Recent 最近使ったファイルから削除 Remove {name} from Recent {name} を最近使ったファイルから削除 Close 閉じる @@ -203,6 +204,10 @@ Restore 元に戻す (縮小) Close All すべて閉じる Close Others ほかを閉じる + New Document… 新規ドキュメント… + Open Document… 開く… + Consolidate All to Here すべてをここに統合 + Move to New Window 新規ウィンドウに移動 Save 保存 Save As… 名前を付けて保存… Save a Copy… コピーを保存… @@ -255,6 +260,7 @@ Create Smart Object after Loading Layers レイヤーの読み込み後にスマートオブジェクトを作成 Reveal in Finder Finder で表示 Show in Explorer エクスプローラーで表示 + Reveal in Explorer エクスプローラーで表示 Show in Folder フォルダーで表示 File Info… ファイル情報… Print… プリント… @@ -343,6 +349,7 @@ Units & Rulers… 単位・定規… Guides, Grid & Slices… ガイド・グリッド・スライス… Plug-ins… プラグイン… + Plugins… プラグイン… Type… 文字… Enhanced Controls… 拡張コントロール… Camera Raw… Camera Raw… @@ -499,6 +506,8 @@ Smart Object スマートオブジェクト Convert to Smart Object スマートオブジェクトに変換 Convert to Embedded 埋め込みに変換 + Embed Linked リンクを埋め込む + Embed All Linked すべてのリンクを埋め込む Convert to Layers レイヤーに変換 Convert to Linked… リンクに変換… Edit Contents コンテンツを編集 @@ -572,7 +581,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 パスの操作 Align 整列 @@ -756,6 +767,7 @@ Minimum… 最小値… Offset… オフセット… Plug-ins プラグイン + Plugins プラグイン Proof Setup 校正設定 Proof Colors 色の校正 Gamut Warning 色域外警告 @@ -764,9 +776,9 @@ Working Magenta Plate 作業用マゼンタ版 Working Yellow Plate 作業用イエロー版 Working Black Plate 作業用ブラック版 - Working CMY Plate 作業用 CMY 版 - Legacy Macintosh RGB 旧 Macintosh RGB - Internet Standard RGB インターネット標準 RGB + Working CMY Plates 作業用 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 旧 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) インターネット標準 RGB (sRGB) Monitor RGB モニター RGB Color Blindness — Protanopia-type 色覚特性 — 1 型(P 型) Color Blindness — Deuteranopia-type 色覚特性 — 2 型(D 型) @@ -858,7 +870,7 @@ Consolidate All to Tabs すべてをタブに統合 Float in Window ウィンドウ内に浮動 Float All in Windows すべてをウィンドウ内に浮動 - New Window for Document ドキュメントの新規ウィンドウ + New Window ドキュメントの新規ウィンドウ Workspace ワークスペース Essentials 基本 Graphic and Web グラフィックと Web @@ -2026,6 +2038,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 カラーピッカー @@ -4245,3 +4262,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… 画像を置き換え… The frame is locked フレームはロックされています The frame is unavailable フレームを利用できません + +# Channels panel menu (#11451) + Channel Options… チャンネルオプション… + 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. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0423429641..b7f16a941e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -166,6 +166,7 @@ Right 오른쪽 Open Recent 최근 파일 열기 Clear Recent Files 최근 파일 목록 지우기 + Clear Recent File List 최근 파일 목록 지우기 Remove from Recent 최근 항목에서 제거 Remove {name} from Recent 최근 항목에서 {name} 제거 Close 닫기 @@ -174,6 +175,10 @@ Restore 이전 크기로 복원 Close All 모두 닫기 Close Others 다른 문서 닫기 + New Document… 새 문서… + Open Document… 열기… + Consolidate All to Here 모두 여기로 통합 + Move to New Window 새 창으로 이동 Save 저장 Save As… 다른 이름으로 저장… Save a Copy… 사본 저장… @@ -226,6 +231,7 @@ Create Smart Object after Loading Layers 레이어를 불러온 후 스마트 오브젝트 만들기 Reveal in Finder Finder에서 보기 Show in Explorer 파일 탐색기에서 보기 + Reveal in Explorer 파일 탐색기에서 보기 Show in Folder 폴더에서 보기 File Info… 파일 정보… Print… 인쇄… @@ -314,6 +320,7 @@ Units & Rulers… 단위 및 눈금자… Guides, Grid & Slices… 안내선, 격자 및 분할 영역… Plug-ins… 플러그인… + Plugins… 플러그인… Type… 문자… Enhanced Controls… 고급 컨트롤… Camera Raw… Camera Raw… @@ -464,6 +471,8 @@ Smart Object 스마트 오브젝트 Convert to Smart Object 스마트 오브젝트로 변환 Convert to Embedded 포함된 오브젝트로 변환 + Embed Linked 연결된 항목 포함 + Embed All Linked 모든 연결된 항목 포함 Convert to Layers 레이어로 변환 Convert to Linked… 연결된 오브젝트로 변환… Edit Contents 내용 편집 @@ -537,7 +546,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 패스 작업 Align 정렬 @@ -721,6 +732,7 @@ Minimum… 최소값… Offset… 오프셋… Plug-ins 플러그인 + Plugins 플러그인 Proof Setup 교정 설정 Proof Colors 교정 색상 Gamut Warning 색상 범위 경고 @@ -729,9 +741,9 @@ Working Magenta Plate 작업용 마젠타 판 Working Yellow Plate 작업용 노랑 판 Working Black Plate 작업용 검정 판 - Working CMY Plate 작업용 CMY 판 - Legacy Macintosh RGB 기존 Macintosh RGB - Internet Standard RGB 인터넷 표준 RGB + Working CMY Plates 작업용 CMY 판 + Legacy Macintosh RGB (Gamma 1.8) 기존 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 인터넷 표준 RGB (sRGB) Monitor RGB 모니터 RGB Color Blindness — Protanopia-type 색각 이상 — 적색맹 Color Blindness — Deuteranopia-type 색각 이상 — 녹색맹 @@ -813,7 +825,7 @@ Consolidate All to Tabs 모두 탭으로 통합 Float in Window 별도 창으로 띄우기 Float All in Windows 모두 별도 창으로 띄우기 - New Window for Document 문서의 새 창 + New Window 문서의 새 창 Workspace 작업 영역 Essentials 필수 Graphic and Web 그래픽 및 웹 @@ -1999,6 +2011,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 색상 피커 @@ -3499,6 +3516,9 @@ curves Smooth 매끄럽게 Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Automatically Create First Snapshot 첫 번째 스냅샷 자동 만들기 + Automatically Create New Snapshot When Saving 저장할 때 새 스냅샷 자동 만들기 + Show New Snapshot Dialog by Default 기본적으로 새 스냅샷 대화 상자 표시 Memory usage percent 메모리 사용 비율(%) Show Favorite Fonts 즐겨찾는 글꼴 표시 No favorite fonts yet: click the star beside a font to add it. 아직 즐겨찾는 글꼴이 없습니다. 글꼴 옆의 별을 클릭하여 추가하세요. @@ -3790,3 +3810,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. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 55f348ff1e..73f521675a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -86,6 +86,7 @@ Right Høyre Open Recent Åpne nylig brukte Clear Recent Files Tøm nylig brukte filer + Clear Recent File List Tøm nylig brukte filer Remove from Recent Fjern fra nylig brukte Remove {name} from Recent Fjern {name} fra nylig brukte Close Lukk @@ -94,6 +95,10 @@ Restore Gjenopprett Close All Lukk alle Close Others Lukk andre + New Document… Nytt dokument… + Open Document… Åpne… + Consolidate All to Here Samle alle her + Move to New Window Flytt til nytt vindu Save Lagre Save As… Lagre som… Save a Copy… Lagre en kopi… @@ -508,7 +513,6 @@ Match Location Samkjør plassering Match Rotation Samkjør rotasjon Match All Samkjør alle - New Window for Document Nytt vindu for dokument Workspace Arbeidsområde Extensions Utvidelser Panels Paneler @@ -686,6 +690,7 @@ Units & Rulers… Enheter og linjaler… Guides, Grid & Slices… Hjelpelinjer, rutenett og segmenter… Plug-ins… Programtillegg… + Plugins… Programtillegg… Technology Previews Teknologiforhåndsvisninger Scratch Disks… Arbeidsdisker… Export Preferences… Eksportinnstillinger… @@ -920,8 +925,8 @@ ACES ACES Industry Standard Bransjestandard Web Web - Internet Standard RGB Internettstandard RGB - Legacy Macintosh RGB Eldre Macintosh RGB + Internet Standard RGB (sRGB) Internettstandard RGB (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Eldre Macintosh RGB (Gamma 1.8) Monitor RGB Skjerm-RGB Windows LCD Windows LCD Coated CMYK Bestrøket CMYK @@ -1742,6 +1747,7 @@ Saved Lagret Reveal in Finder Vis i Finder Show in Explorer Vis i Utforsker + Reveal in Explorer Vis i Utforsker Show in Folder Vis i mappe Before (Y) Før (Y) Save Settings… Lagre innstillinger… @@ -1921,6 +1927,8 @@ Combine Shapes Kombiner former Contract… Trekk sammen… Convert to Embedded Konverter til innebygd + Embed Linked Bygg inn koblet + Embed All Linked Bygg inn alle koblede Convert to Layers Konverter til lag Convert to Linked… Konverter til koblet… Count Telling @@ -2008,6 +2016,7 @@ Pixelate Pikseler Place Scale Marker… Plasser skalamarkør… Plug-ins Programtillegg + Plugins Programtillegg Print One Copy Skriv ut ett eksemplar Print… Skriv ut… Range Område @@ -2052,7 +2061,7 @@ Vertical Centers Loddrette sentre Video Frames to Layers… Videorammer til lag… WIA Support… WIA-støtte… - Working CMY Plate Arbeidsplate for CMY + Working CMY Plates Arbeidsplate for CMY # tl! literals (third pass) Absolute Absolutt @@ -2338,6 +2347,11 @@ Show canvas dots Vis lerretprikker Clip thumbnails to layer bounds Klipp miniatyrer til laggrensene Clip Thumbnails to Layer Bounds Klipp miniatyrer til laggrensene + No Thumbnails Ingen miniatyrer + Small Thumbnails Små miniatyrer + Medium Thumbnails Middels miniatyrer + Large Thumbnails Store miniatyrer + Clip Thumbnails to Document Bounds Klipp miniatyrer til dokumentgrensene Canvas color Lerretfarge Canvas custom color Egendefinert lerretfarge Classic Klassisk @@ -4787,3 +4801,11 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Pause For Pause i seconds sekunder Create a single history state Opprett én historikktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalalternativer… + Specify Channels: Angi kanaler: + Not enough grayscale documents for this mode. Ikke nok dokumenter i gråtoner for denne modusen. + Pick a different document for each channel. Velg et annet dokument for hver kanal. + A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. + A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ef2df8db00..169db2173c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -128,6 +128,7 @@ Right Rechts Open Recent Recent geopend Clear Recent Files Recente bestanden wissen + Clear Recent File List Recente bestanden wissen Remove from Recent Verwijderen uit recente bestanden Remove {name} from Recent {name} verwijderen uit recente bestanden Close Sluiten @@ -136,6 +137,10 @@ Restore Herstellen Close All Alles sluiten Close Others Andere sluiten + New Document… Nieuw document… + Open Document… Openen… + Consolidate All to Here Alles hier samenvoegen + Move to New Window Verplaatsen naar nieuw venster Save Opslaan Save As… Opslaan als… Save a Copy… Kopie opslaan… @@ -187,6 +192,7 @@ Create Smart Object after Loading Layers Slim object maken na laden van lagen Reveal in Finder Weergeven in Finder Show in Explorer Weergeven in Verkenner + Reveal in Explorer Weergeven in Verkenner Show in Folder Weergeven in map File Info… Bestandsinformatie… Print… Afdrukken… @@ -275,6 +281,7 @@ Units & Rulers… Eenheden en linialen… Guides, Grid & Slices… Hulplijnen, raster en segmenten… Plug-ins… Plug-ins… + Plugins… Plug-ins… Type… Tekst… Enhanced Controls… Uitgebreide bediening… Camera Raw… Camera Raw… @@ -431,6 +438,8 @@ Smart Object Slim object Convert to Smart Object Omzetten naar slim object Convert to Embedded Omzetten naar ingesloten + Embed Linked Gekoppeld insluiten + Embed All Linked Alles gekoppeld insluiten Convert to Layers Omzetten naar lagen Convert to Linked… Omzetten naar gekoppeld… Edit Contents Inhoud bewerken @@ -504,7 +513,9 @@ Unite Shapes Vormen verenigen Subtract Front Shape Voorste vorm aftrekken Intersect Shape Areas Overlappende vormgebieden + Unite Shapes at Overlap Overlappende vormgebieden Exclude Overlapping Shapes Overlappende vormen uitsluiten + Subtract Shapes at Overlap Overlappende vormen uitsluiten Merge Shape Components Vormcomponenten samenvoegen Path operations Padbewerkingen Align Uitlijnen @@ -687,6 +698,7 @@ Minimum… Minimum… Offset… Verschuiving… Plug-ins Plug-ins + Plugins Plug-ins Proof Setup Proefinstellingen Proof Colors Proefkleuren Gamut Warning Waarschuwing kleurbereik @@ -695,9 +707,9 @@ Working Magenta Plate Werkplaat magenta Working Yellow Plate Werkplaat geel Working Black Plate Werkplaat zwart - Working CMY Plate Werkplaat CMY - Legacy Macintosh RGB Verouderde Macintosh RGB - Internet Standard RGB Internetstandaard RGB + Working CMY Plates Werkplaat CMY + Legacy Macintosh RGB (Gamma 1.8) Verouderde Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internetstandaard RGB (sRGB) Monitor RGB Monitor-RGB Color Blindness — Protanopia-type Kleurenblindheid — type protanopie Color Blindness — Deuteranopia-type Kleurenblindheid — type deuteranopie @@ -779,7 +791,6 @@ Consolidate All to Tabs Alles samenvoegen in tabbladen Float in Window Zwevend in venster Float All in Windows Alles zwevend in vensters - New Window for Document Nieuw venster voor document Workspace Werkruimte Essentials Basis Graphic and Web Grafisch en web @@ -1948,6 +1959,11 @@ Show canvas dots Canvasstippen tonen Clip thumbnails to layer bounds Miniaturen bijsnijden tot laaggrenzen Clip Thumbnails to Layer Bounds Miniaturen bijsnijden tot laaggrenzen + No Thumbnails Geen miniaturen + Small Thumbnails Kleine miniaturen + Medium Thumbnails Middelgrote miniaturen + Large Thumbnails Grote miniaturen + Clip Thumbnails to Document Bounds Miniaturen bijsnijden tot documentgrenzen Canvas color Canvaskleur Canvas custom color Aangepaste canvaskleur Color picker Kleurkiezer @@ -4322,3 +4338,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Afbeelding vervangen… The frame is locked Het kader is vergrendeld The frame is unavailable Het kader is niet beschikbaar + +# Channels panel menu (#11451) + New Channel… Nieuw kanaal… + Duplicate Channel Kanaal dupliceren + Delete Channel Kanaal verwijderen + New Spot Channel… Nieuw steunkleurkanaal… + Merge Spot Channel Steunkleurkanaal samenvoegen + Channel Options… Kanaalopties… + Split Channels Kanalen splitsen + Merge Channels… Kanalen samenvoegen… + Specify Channels: Kanalen opgeven: + Not enough grayscale documents for this mode. Niet genoeg grijswaardendocumenten voor deze modus. + Pick a different document for each channel. Kies voor elk kanaal een ander document. + A picked document is no longer open. Een gekozen document is niet meer geopend. + A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 41cd0e671d..6aae9b3d84 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -171,6 +171,7 @@ Right Prawa Open Recent Otwórz ostatnie Clear Recent Files Wyczyść listę ostatnich plików + Clear Recent File List Wyczyść listę ostatnich plików Remove from Recent Usuń z ostatnich plików Remove {name} from Recent Usuń {name} z ostatnich plików Close Zamknij @@ -179,6 +180,10 @@ Restore Przywróć Close All Zamknij wszystko Close Others Zamknij pozostałe + New Document… Nowy dokument… + Open Document… Otwórz… + Consolidate All to Here Połącz wszystko tutaj + Move to New Window Przenieś do nowego okna Save Zapisz Save As… Zapisz jako… Save a Copy… Zapisz kopię… @@ -231,6 +236,7 @@ Create Smart Object after Loading Layers Utwórz obiekt inteligentny po wczytaniu warstw Reveal in Finder Pokaż w Finderze Show in Explorer Pokaż w Eksploratorze + Reveal in Explorer Pokaż w Eksploratorze Show in Folder Pokaż w folderze File Info… Informacje o pliku… Print… Drukuj… @@ -318,6 +324,7 @@ Units & Rulers… Jednostki i linijki… Guides, Grid & Slices… Prowadnice, siatka i plasterki… Plug-ins… Wtyczki… + Plugins… Wtyczki… Type… Tekst… Enhanced Controls… Ulepszone elementy sterujące… Camera Raw… Camera Raw… @@ -474,6 +481,8 @@ Smart Object Obiekt inteligentny Convert to Smart Object Konwertuj na obiekt inteligentny Convert to Embedded Konwertuj na osadzony + Embed Linked Osadź połączony + Embed All Linked Osadź wszystkie połączone Convert to Layers Konwertuj na warstwy Convert to Linked… Konwertuj na połączony… Edit Contents Edytuj zawartość @@ -547,7 +556,9 @@ Unite Shapes Złącz kształty Subtract Front Shape Odejmij kształt z przodu Intersect Shape Areas Część wspólna obszarów kształtów + Unite Shapes at Overlap Część wspólna obszarów kształtów Exclude Overlapping Shapes Wyklucz nakładające się kształty + Subtract Shapes at Overlap Wyklucz nakładające się kształty Merge Shape Components Scal składniki kształtu Path operations Operacje na ścieżkach Align Wyrównaj @@ -732,6 +743,7 @@ Minimum… Minimum… Offset… Przesunięcie… Plug-ins Wtyczki + Plugins Wtyczki Proof Setup Ustawienia próbnego wydruku Proof Colors Kolory próbne Gamut Warning Ostrzeżenie o gamucie @@ -740,9 +752,9 @@ Working Magenta Plate Robocza płyta magenty Working Yellow Plate Robocza płyta żółci Working Black Plate Robocza płyta czerni - Working CMY Plate Robocza płyta CMY - Legacy Macintosh RGB Starszy Macintosh RGB - Internet Standard RGB Standardowy RGB dla Internetu + Working CMY Plates Robocza płyta CMY + Legacy Macintosh RGB (Gamma 1.8) Starszy Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Standardowy RGB dla Internetu (sRGB) Monitor RGB RGB monitora Color Blindness — Protanopia-type Ślepota barw — typ protanopii Color Blindness — Deuteranopia-type Ślepota barw — typ deuteranopii @@ -834,7 +846,7 @@ Consolidate All to Tabs Połącz wszystko w karty Float in Window Odłącz do okna Float All in Windows Odłącz wszystko do okien - New Window for Document Nowe okno dla dokumentu + New Window Nowe okno dla dokumentu Workspace Obszar roboczy Essentials Podstawowe Graphic and Web Grafika i Internet @@ -2012,6 +2024,11 @@ Show canvas dots Pokaż kropki płótna Clip thumbnails to layer bounds Przycinaj miniatury do granic warstwy Clip Thumbnails to Layer Bounds Przycinaj miniatury do granic warstwy + No Thumbnails Bez miniaturek + Small Thumbnails Małe miniaturki + Medium Thumbnails Średnie miniaturki + Large Thumbnails Duże miniaturki + Clip Thumbnails to Document Bounds Przycinaj miniatury do granic dokumentu Canvas color Kolor płótna Canvas custom color Własny kolor płótna Color picker Próbnik kolorów @@ -4185,3 +4202,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Zastąp obraz… The frame is locked Ramka jest zablokowana The frame is unavailable Ramka jest niedostępna + +# Channels panel menu (#11451) + New Channel… Nowy kanał… + Duplicate Channel Duplikuj kanał + Delete Channel Usuń kanał + New Spot Channel… Nowy kanał kolorów dodatkowych… + Merge Spot Channel Scal kanał kolorów dodatkowych + Channel Options… Opcje kanału… + Split Channels Podziel kanały + Merge Channels… Scal kanały… + Specify Channels: Określ kanały: + Not enough grayscale documents for this mode. Za mało dokumentów w skali szarości dla tego trybu. + Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. + A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. + A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index db66f03f90..bee618bcbd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -168,6 +168,7 @@ Right Direita Open Recent Abrir recentes Clear Recent Files Limpar arquivos recentes + Clear Recent File List Limpar arquivos recentes Remove from Recent Remover dos recentes Remove {name} from Recent Remover {name} dos recentes Close Fechar @@ -176,6 +177,10 @@ Restore Restaurar Close All Fechar tudo Close Others Fechar outros + New Document… Novo documento… + Open Document… Abrir… + Consolidate All to Here Consolidar tudo aqui + Move to New Window Mover para nova janela Save Salvar Save As… Salvar como… Save a Copy… Salvar uma cópia… @@ -227,6 +232,7 @@ Create Smart Object after Loading Layers Criar objeto inteligente após carregar as camadas Reveal in Finder Mostrar no Finder Show in Explorer Mostrar no Explorador + Reveal in Explorer Mostrar no Explorador Show in Folder Mostrar na pasta File Info… Informações do arquivo… Print… Imprimir… @@ -313,6 +319,7 @@ Units & Rulers… Unidades e réguas… Guides, Grid & Slices… Guias, grade e fatias… Plug-ins… Plug-ins… + Plugins… Plug-ins… Type… Tipo… Enhanced Controls… Controles aprimorados… Camera Raw… Camera Raw… @@ -462,6 +469,8 @@ Smart Object Objeto inteligente Convert to Smart Object Converter em objeto inteligente Convert to Embedded Converter em incorporado + Embed Linked Incorporar vinculado + Embed All Linked Incorporar todos os vinculados Convert to Layers Converter em camadas Convert to Linked… Converter em vinculado… Edit Contents Editar conteúdo @@ -712,6 +721,7 @@ Minimum… Mínimo… Offset… Deslocamento… Plug-ins Plug-ins + Plugins Plug-ins Proof Setup Configuração de prova Proof Colors Cores de prova Gamut Warning Aviso de gama @@ -720,9 +730,9 @@ Working Magenta Plate Chapa magenta de trabalho Working Yellow Plate Chapa amarela de trabalho Working Black Plate Chapa preta de trabalho - Working CMY Plate Chapa CMY de trabalho - Legacy Macintosh RGB RGB Macintosh legado - Internet Standard RGB RGB padrão da Internet + Working CMY Plates Chapa CMY de trabalho + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh legado (Gamma 1.8) + Internet Standard RGB (sRGB) RGB padrão da Internet (sRGB) Monitor RGB RGB do monitor Color Blindness — Protanopia-type Daltonismo — tipo protanopia Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia @@ -814,7 +824,6 @@ Consolidate All to Tabs Consolidar tudo em guias Float in Window Flutuar na janela Float All in Windows Flutuar tudo em janelas - New Window for Document Nova janela para o documento Workspace Espaço de trabalho Essentials Essenciais Graphic and Web Gráficos e Web @@ -1973,6 +1982,11 @@ Show canvas dots Mostrar pontos da tela de pintura Clip thumbnails to layer bounds Recortar miniaturas aos limites da camada Clip Thumbnails to Layer Bounds Recortar miniaturas aos limites da camada + No Thumbnails Sem miniaturas + Small Thumbnails Miniaturas pequenas + Medium Thumbnails Miniaturas médias + Large Thumbnails Miniaturas grandes + Clip Thumbnails to Document Bounds Recortar miniaturas aos limites do documento Canvas color Cor da tela de pintura Canvas custom color Cor personalizada da tela de pintura Color picker Seletor de cores @@ -4491,3 +4505,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Substituir imagem… The frame is locked A moldura está bloqueada The frame is unavailable A moldura não está disponível + +# Channels panel menu (#11451) + Channel Options… Opções de canal… + Specify Channels: Especificar canais: + Not enough grayscale documents for this mode. Não há documentos em tons de cinza suficientes para este modo. + Pick a different document for each channel. Escolha um documento diferente para cada canal. + A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. + A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e54f6ff397..a9daa9c8f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -272,6 +272,7 @@ Right Правый Open Recent Открыть недавние Clear Recent Files Очистить список недавних файлов + Clear Recent File List Очистить список недавних файлов Remove from Recent Удалить из недавних Remove {name} from Recent Удалить {name} из недавних Close Закрыть @@ -280,6 +281,10 @@ Restore Свернуть в окно Close All Закрыть все Close Others Закрыть остальные + New Document… Новый документ… + Open Document… Открыть… + Consolidate All to Here Объединить всё здесь + Move to New Window Переместить в новое окно Save Сохранить Save As… Сохранить как… Save a Copy… Сохранить копию… @@ -676,7 +681,6 @@ Match Location Согласовать положение Match Rotation Согласовать поворот Match All Согласовать всё - New Window for Document Новое окно документа Workspace Рабочая среда Extensions Расширения Panels Панели @@ -846,6 +850,7 @@ Units & Rulers… Единицы и линейки… Guides, Grid & Slices… Направляющие, сетка и фрагменты… Plug-ins… Плагины… + Plugins… Плагины… Technology Previews Предпросмотр технологий Scratch Disks… Рабочие диски… Export Preferences… Экспорт настроек… @@ -1068,8 +1073,8 @@ ACES ACES Industry Standard Отраслевой стандарт Web Web - Internet Standard RGB Стандартный RGB для Интернета - Legacy Macintosh RGB Устаревший RGB Macintosh + Internet Standard RGB (sRGB) Стандартный RGB для Интернета (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Устаревший RGB Macintosh (Gamma 1.8) Monitor RGB Монитор RGB Windows LCD Windows LCD Coated CMYK Мелованная бумага CMYK @@ -1853,6 +1858,7 @@ Saved Сохранено Reveal in Finder Показать в Finder Show in Explorer Показать в Проводнике + Reveal in Explorer Показать в Проводнике Show in Folder Показать в папке Before (Y) До (Y) Save Settings… Сохранить настройки… @@ -2018,6 +2024,8 @@ Combine Shapes Комбинировать фигуры Contract… Сжать… Convert to Embedded Преобразовать во встроенный + Embed Linked Встроить связанный + Embed All Linked Встроить все связанные Convert to Layers Преобразовать в слои Convert to Linked… Преобразовать в связанный… Count Счёт @@ -2106,6 +2114,7 @@ Pixelate Оформление пикселями Place Scale Marker… Шкала масштаба… Plug-ins Плагины + Plugins Плагины Print One Copy Печать одной копии Print… Печать… Range Диапазон @@ -2150,7 +2159,7 @@ Vertical Centers Центры по вертикали Video Frames to Layers… Видеокадры в слои… WIA Support… Поддержка WIA… - Working CMY Plate Рабочая голубая форма + Working CMY Plates Рабочая голубая форма Absolute Абсолютный Action Операция Adaptive Адаптивный @@ -2431,6 +2440,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 Свой цвет холста Classic Классическая @@ -4762,3 +4776,11 @@ shape Appearance Оформление Replace Image… Заменить изображение… The frame is locked Рамка заблокирована The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + Channel Options… Параметры канала… + 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. Один из выбранных документов больше не является документом в градациях серого того же размера. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b5eef1478..4ff32db75a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -196,6 +196,7 @@ Right Desno Open Recent Otvori nedavno Clear Recent Files Obriši spisak nedavnih datoteka + Clear Recent File List Obriši spisak nedavnih datoteka Remove from Recent Ukloni iz nedavnih Remove {name} from Recent Ukloni {name} iz nedavnih Close Zatvori @@ -204,6 +205,10 @@ Restore Vrati Close All Zatvori sve Close Others Zatvori ostale + New Document… Novi dokument… + Open Document… Otvori… + Consolidate All to Here Objedini sve ovde + Move to New Window Premesti u novi prozor Save Sačuvaj Save As… Sačuvaj kao… Save a Copy… Sačuvaj kopiju… @@ -255,6 +260,7 @@ Create Smart Object after Loading Layers Napravi pametni objekat posle 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 fascikli File Info… Informacije o datoteci… Print… Štampaj… @@ -339,6 +345,7 @@ Units & Rulers… Jedinice i lenjiri… Guides, Grid & Slices… Vodilje, mreža i isečci… Plug-ins… Dodaci… + Plugins… Dodaci… Type… Tekst… Enhanced Controls… Napredne kontrole… Camera Raw… Camera Raw… @@ -487,6 +494,8 @@ Smart Object Pametni objekat Convert to Smart Object Pretvori u pametni objekat Convert to Embedded Pretvori u ugrađeni + Embed Linked Ugradi povezani + Embed All Linked Ugradi sve povezane Convert to Layers Pretvori u slojeve Convert to Linked… Pretvori u povezani… Edit Contents Uredi sadržaj @@ -738,6 +747,7 @@ Minimum… Minimum… Offset… Pomak… Plug-ins Dodaci + Plugins Dodaci Proof Setup Podešavanje probnog prikaza Proof Colors Probne boje Gamut Warning Upozorenje o gamutu @@ -746,9 +756,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 Stari Macintosh RGB - Internet Standard RGB Internet standard RGB + Working CMY Plates Radna CMY ploča + Legacy Macintosh RGB (Gamma 1.8) Stari Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internet standard RGB (sRGB) Monitor RGB RGB monitora Color Blindness — Protanopia-type Daltonizam — tip protanopije Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije @@ -840,7 +850,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 Osnovno Graphic and Web Grafika i veb @@ -1989,6 +1998,11 @@ Show canvas dots Prikaži tačke na platnu Clip thumbnails to layer bounds Iseci sličice prema granicama sloja Clip Thumbnails to Layer Bounds Iseci sličice prema granicama 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 Iseci sličice prema granicama dokumenta Canvas color Boja platna Canvas custom color Prilagođena boja platna Color picker Birač boja @@ -4643,3 +4657,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замени слику… The frame is locked Оквир је закључан The frame is unavailable Оквир није доступан + +# 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 režim. + Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan izabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 928a735904..4c9e6a12f6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -84,6 +84,7 @@ Right Höger Open Recent Öppna senaste Clear Recent Files Rensa senaste filer + Clear Recent File List Rensa senaste filer Remove from Recent Ta bort från senaste Remove {name} from Recent Ta bort {name} från senaste Close Stäng @@ -92,6 +93,10 @@ Restore Återställ Close All Stäng alla Close Others Stäng övriga + New Document… Nytt dokument… + Open Document… Öppna… + Consolidate All to Here Samla alla här + Move to New Window Flytta till nytt fönster Save Spara Save As… Spara som… Save a Copy… Spara en kopia… @@ -506,7 +511,6 @@ Match Location Matcha plats Match Rotation Matcha rotation Match All Matcha alla - New Window for Document Nytt fönster för dokument Workspace Arbetsyta Extensions Tillägg Panels Paneler @@ -684,6 +688,7 @@ Units & Rulers… Enheter och linjaler… Guides, Grid & Slices… Stödlinjer, rutnät och skivor… Plug-ins… Plugin-program… + Plugins… Plugin-program… Technology Previews Teknikförhandsgranskningar Scratch Disks… Arbetsdiskar… Export Preferences… Exportinställningar… @@ -918,8 +923,8 @@ ACES ACES Industry Standard Branschstandard Web Webb - 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 Bildskärms-RGB Windows LCD Windows LCD Coated CMYK Bestruket CMYK @@ -1740,6 +1745,7 @@ Saved Sparat Reveal in Finder Visa i Finder Show in Explorer Visa i Utforskaren + Reveal in Explorer Visa i Utforskaren Show in Folder Visa i mapp Before (Y) Före (Y) Save Settings… Spara inställningar… @@ -1919,6 +1925,8 @@ Combine Shapes Kombinera former Contract… Dra ihop… Convert to Embedded Konvertera till inbäddat + Embed Linked Bädda in länkat + Embed All Linked Bädda in alla länkade Convert to Layers Konvertera till lager Convert to Linked… Konvertera till länkat… Count Antal @@ -2006,6 +2014,7 @@ Pixelate Pixla Place Scale Marker… Placera skalmarkör… Plug-ins Plugin-program + Plugins Plugin-program Print One Copy Skriv ut en kopia Print… Skriv ut… Range Intervall @@ -2050,7 +2059,7 @@ Vertical Centers Lodräta mittpunkter Video Frames to Layers… Videobildrutor till lager… WIA Support… WIA-stöd… - Working CMY Plate CMY-arbetsplåt + Working CMY Plates CMY-arbetsplåt # tl! literals (third pass) Absolute Absolut @@ -2336,6 +2345,11 @@ Show canvas dots Visa punkter på arbetsytan Clip thumbnails to layer bounds Begränsa miniatyrer till lagergränser Clip Thumbnails to Layer Bounds Begränsa miniatyrer till lagergränser + No Thumbnails Inga miniatyrer + Small Thumbnails Små miniatyrer + Medium Thumbnails Medelstora miniatyrer + Large Thumbnails Stora miniatyrer + Clip Thumbnails to Document Bounds Begränsa miniatyrer till dokumentgränser Canvas color Arbetsytans färg Canvas custom color Anpassad färg för arbetsytan Classic Klassisk @@ -4785,3 +4799,11 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Pause For Paus i seconds sekunder Create a single history state Skapa ett enda historikläge + +# Channels panel menu (#11451) + Channel Options… Kanalalternativ… + Specify Channels: Ange kanaler: + Not enough grayscale documents for this mode. Det finns inte tillräckligt många gråskaledokument för det här läget. + Pick a different document for each channel. Välj ett annat dokument för varje kanal. + A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. + A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d319a3614c..7b7108878d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -95,6 +95,7 @@ Right Sağ Open Recent Son Kullanılanları Aç Clear Recent Files Son Dosyaları Temizle + Clear Recent File List Son Dosyaları Temizle Remove from Recent Son Kullanılanlardan Kaldır Remove {name} from Recent {name} öğesini Son Kullanılanlardan kaldır Close Kapat @@ -103,6 +104,10 @@ Restore Geri yükle Close All Tümünü Kapat Close Others Diğerlerini Kapat + New Document… Yeni Belge… + Open Document… Aç… + Consolidate All to Here Tümünü Buraya Birleştir + Move to New Window Yeni Pencereye Taşı Save Kaydet Save As… Farklı Kaydet… Save a Copy… Bir Kopyasını Kaydet… @@ -154,6 +159,7 @@ Create Smart Object after Loading Layers Katmanları Yükledikten Sonra Akıllı Nesne Oluştur Reveal in Finder Finder'da Göster Show in Explorer Explorer'da Göster + Reveal in Explorer Explorer'da Göster Show in Folder Klasörde Göster File Info… Dosya Bilgileri… Print… Yazdır… @@ -242,6 +248,7 @@ Units & Rulers… Birimler ve Cetveller… Guides, Grid & Slices… Kılavuzlar, Izgaralar ve Dilimler… Plug-ins… Eklentiler… + Plugins… Eklentiler… Type… Metin… Enhanced Controls… Gelişmiş Kontroller… Camera Raw… Camera Raw… @@ -397,6 +404,8 @@ Smart Object Akıllı Nesne Convert to Smart Object Akıllı Nesneye Dönüştür Convert to Embedded Gömülüye Dönüştür + Embed Linked Bağlantılıyı Göm + Embed All Linked Tüm Bağlantılıları Göm Convert to Layers Katmanlara Dönüştür Convert to Linked… Bağlantılıya Dönüştür… Edit Contents İçeriği Düzenle @@ -469,7 +478,9 @@ Unite Shapes Şekil Alanlarını Birleştir Subtract Front Shape Ön Şekli Çıkar Intersect Shape Areas Şekil Alanlarını Kesiştir + Unite Shapes at Overlap Şekil Alanlarını Kesiştir Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut + Subtract Shapes at Overlap Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir Path operations Yol işlemleri Align Hizala @@ -652,6 +663,7 @@ Minimum… Minimum… Offset… Kaydırma… Plug-ins Eklentiler + Plugins Eklentiler Proof Setup Prova Kurulumu Proof Colors Prova Renkleri Gamut Warning Gamut Uyarısı @@ -660,9 +672,9 @@ Working Magenta Plate Çalışma Macenta Plakası Working Yellow Plate Çalışma Sarı Plakası Working Black Plate Çalışma Siyah Plakası - Working CMY Plate Çalışma CMY Plakası - Legacy Macintosh RGB Eski Macintosh RGB - Internet Standard RGB İnternet Standardı RGB + Working CMY Plates Çalışma CMY Plakası + Legacy Macintosh RGB (Gamma 1.8) Eski Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) İnternet Standardı RGB (sRGB) Monitor RGB Monitör RGB Color Blindness — Protanopia-type Renk Körlüğü — Protanopi tipi Color Blindness — Deuteranopia-type Renk Körlüğü — Döteranopi tipi @@ -744,7 +756,6 @@ Consolidate All to Tabs Tümünü Sekmelerde Birleştir Float in Window Pencerede Yüzdür Float All in Windows Tümünü Pencerelerde Yüzdür - New Window for Document Belge için Yeni Pencere Workspace Çalışma alanı Essentials Temel Öğeler Graphic and Web Grafik ve Web @@ -1901,6 +1912,11 @@ Show canvas dots Tuval noktalarını göster Clip thumbnails to layer bounds Küçük resimleri katman sınırlarına kırp Clip Thumbnails to Layer Bounds Küçük resimleri katman sınırlarına kırp + No Thumbnails Küçük Resim Yok + Small Thumbnails Küçük Boyutlu Küçük Resimler + Medium Thumbnails Orta Boyutlu Küçük Resimler + Large Thumbnails Büyük Boyutlu Küçük Resimler + Clip Thumbnails to Document Bounds Küçük resimleri belge sınırlarına kırp Canvas color Tuval rengi Canvas custom color Tuval özel rengi Color picker Renk seçici @@ -4299,3 +4315,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Görüntüyü Değiştir… The frame is locked Çerçeve kilitli The frame is unavailable Çerçeve kullanılamıyor + +# Channels panel menu (#11451) + New Channel… Yeni Kanal… + Duplicate Channel Kanalı Çoğalt + Delete Channel Kanalı Sil + New Spot Channel… Yeni Spot Kanal… + Merge Spot Channel Spot Kanalı Birleştir + Channel Options… Kanal Seçenekleri… + Split Channels Kanalları Ayır + Merge Channels… Kanalları Birleştir… + Specify Channels: Kanalları Belirt: + Not enough grayscale documents for this mode. Bu mod için yeterli gri tonlamalı belge yok. + Pick a different document for each channel. Her kanal için farklı bir belge seçin. + A picked document is no longer open. Seçilen belgelerden biri artık açık değil. + A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1621be051..327d20fb7f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -255,6 +255,7 @@ Right Правий Open Recent Нещодавні файли Clear Recent Files Очистити список нещодавніх файлів + Clear Recent File List Очистити список нещодавніх файлів Remove from Recent Вилучити з нещодавніх Remove {name} from Recent Вилучити {name} з нещодавніх Close Закрити @@ -263,6 +264,10 @@ Restore Відновити Close All Закрити всі Close Others Закрити інші + New Document… Новий документ… + Open Document… Відкрити… + Consolidate All to Here Об'єднати все тут + Move to New Window Перемістити в нове вікно Save Зберегти Save As… Зберегти як… Save a Copy… Зберегти копію… @@ -646,7 +651,7 @@ Match Location Узгодити розташування Match Rotation Узгодити поворот Match All Узгодити все - New Window for Document Нове вікно для документа + New Window Нове вікно для документа Workspace Робочий простір Extensions Розширення Panels Панелі @@ -816,6 +821,7 @@ Units & Rulers… Одиниці та лінійки… Guides, Grid & Slices… Напрямні, сітка та фрагменти… Plug-ins… Плагіни… + Plugins… Плагіни… Technology Previews Попередній перегляд технологій Scratch Disks… Робочі диски… Export Preferences… Налаштування експорту… @@ -1038,8 +1044,8 @@ ACES ACES Industry Standard Галузевий стандарт Web Веб - Internet Standard RGB Стандартний RGB для інтернету - Legacy Macintosh RGB Застарілий Macintosh RGB + Internet Standard RGB (sRGB) Стандартний RGB для інтернету (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (Gamma 1.8) Monitor RGB RGB монітора Windows LCD Windows LCD Coated CMYK CMYK для крейдованого паперу @@ -1822,6 +1828,7 @@ Saved Збережено Reveal in Finder Показати у Finder Show in Explorer Показати у Провіднику + Reveal in Explorer Показати у Провіднику Show in Folder Показати в теці Before (Y) До (Y) Save Settings… Зберегти налаштування… @@ -1985,6 +1992,8 @@ Combine Shapes Об'єднати фігури Contract… Стиснути… Convert to Embedded Перетворити на вбудований + Embed Linked Вбудувати пов'язаний + Embed All Linked Вбудувати всі пов'язані Convert to Layers Перетворити на шари Convert to Linked… Перетворити на пов'язаний… Count Підрахунок @@ -2007,6 +2016,7 @@ Enhanced Controls… Розширені елементи керування… Entropy Ентропія Exclude Overlapping Shapes Виключити перекриття фігур + Subtract Shapes at Overlap Виключити перекриття фігур Exit Вийти Expand… Розширити… Extra Large Дуже великий @@ -2032,6 +2042,7 @@ Indexed Color… Індексований колір… Integrations… Інтеграції… Intersect Shape Areas Перетин областей фігур + Unite Shapes at Overlap Перетин областей фігур Kurtosis Ексцес Large Великий Layer Edges Краї шару @@ -2073,6 +2084,7 @@ Pixelate Пікселізація Place Scale Marker… Розмістити маркер масштабу… Plug-ins Плагіни + Plugins Плагіни Print One Copy Надрукувати одну копію Print… Друк… Range Діапазон @@ -2117,7 +2129,7 @@ Vertical Centers Вертикальні центри Video Frames to Layers… Відеокадри в шари… WIA Support… Підтримка WIA… - Working CMY Plate Робоча форма CMY + Working CMY Plates Робоча форма CMY Absolute Абсолютний Action Дія Adaptive Адаптивний @@ -2394,6 +2406,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 Довільний колір полотна Classic Класична @@ -4278,3 +4295,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замінити зображення… The frame is locked Рамку заблоковано The frame is unavailable Рамка недоступна + +# 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. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index de99217a09..9890267d85 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -188,10 +188,15 @@ Check Spelling… Kiểm tra chính tả… Clear Xóa Clear Recent Files Xóa tệp gần đây + Clear Recent File List Xóa tệp gần đây Clipboard Bảng tạm Close Đóng Close All Đóng tất cả Close Others Đóng các tệp khác + New Document… Tài liệu mới… + Open Document… Mở… + Consolidate All to Here Gom tất cả vào đây + Move to New Window Di chuyển sang cửa sổ mới Color Lookup Tables… Bảng tra màu… Color Settings… Cài đặt màu… Conditional Mode Change… Đổi chế độ có điều kiện… @@ -275,6 +280,7 @@ Place Embedded… Đặt nhúng… Place Linked… Đặt liên kết… Plug-ins… Plug-in… + Plugins… Plug-in… Preferences Tùy chọn Preset Manager… Quản lý thiết lập sẵn… Presets Thiết lập sẵn @@ -373,6 +379,8 @@ Color Table… Bảng màu… Combine Shapes Kết hợp hình Convert to Embedded Chuyển thành nhúng + Embed Linked Nhúng mục liên kết + Embed All Linked Nhúng tất cả mục liên kết Convert to Layers Chuyển thành lớp Convert to Linked… Chuyển thành liên kết… Convert to Smart Object Chuyển thành đối tượng thông minh @@ -404,6 +412,7 @@ Entropy Entropy Equalize Cân bằng histogram Exclude Overlapping Shapes Loại phần chồng hình + Subtract Shapes at Overlap Loại phần chồng hình Export Contents… Xuất nội dung… Exposure… Phơi sáng… Fill Content Nội dung tô @@ -438,6 +447,7 @@ Insert Blank Frame Chèn khung trống Interpret Footage… Diễn giải footage… Intersect Shape Areas Giao vùng hình + Unite Shapes at Overlap Giao vùng hình Invert Đảo màu Invert… Đảo màu… Kurtosis Kurtosis @@ -733,10 +743,10 @@ Grid Lưới Guides Đường căn HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) - Internet Standard RGB RGB chuẩn Internet + Internet Standard RGB (sRGB) RGB chuẩn Internet (sRGB) Layer Edges Cạnh lớp Layers Các lớp - Legacy Macintosh RGB RGB Macintosh cũ + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh cũ (Gamma 1.8) Lock Guides Khóa đường căn Lock Slices Khóa lát cắt Mesh Lưới @@ -766,7 +776,7 @@ Standard Screen Mode Chế độ màn hình chuẩn Target Path Đường dẫn đích Working Black Plate Bản Black làm việc - Working CMY Plate Bản CMY làm việc + Working CMY Plates Bản CMY làm việc Working CMYK CMYK làm việc Working Cyan Plate Bản Cyan làm việc Working Magenta Plate Bản Magenta làm việc @@ -813,7 +823,7 @@ Modifier Keys Phím bổ trợ Motion Chuyển động Navigator Điều hướng - New Window for Document Cửa sổ mới cho tài liệu + New Window Cửa sổ mới cho tài liệu New Workspace… Không gian làm việc mới… Next Theme Chủ đề tiếp theo Options Thanh tùy chọn @@ -1605,6 +1615,7 @@ cameraRaw Shadows Vùng tối Picas Pica Pixels Pixel Plug-ins Plug-in + Plugins Plug-in Png PNG Point size Cỡ point Points Point @@ -2614,6 +2625,7 @@ shape Appearance Diện mạo (coming soon) (sắp có) Add beauty precomp from the AOVs Thêm precomp beauty từ các AOV Show in Explorer Hiện trong Explorer + Reveal in Explorer Hiện trong Explorer Show in Folder Hiện trong thư mục Delete and Fill Selection Xoá và tô vùng chọn Transform Again on a Copy Biến đổi lại trên một bản sao @@ -2755,6 +2767,11 @@ shape Appearance Diện mạo Show canvas dots Hiện chấm vùng vẽ Clip thumbnails to layer bounds Cắt hình thu nhỏ theo biên của lớp Clip Thumbnails to Layer Bounds Cắt hình thu nhỏ theo biên của lớp + No Thumbnails Không có hình thu nhỏ + Small Thumbnails Hình thu nhỏ cỡ nhỏ + Medium Thumbnails Hình thu nhỏ cỡ vừa + Large Thumbnails Hình thu nhỏ cỡ lớn + Clip Thumbnails to Document Bounds Cắt hình thu nhỏ theo biên của tài liệu Color picker Bộ chọn màu Color space Không gian màu Column width Độ rộng cột @@ -3761,6 +3778,10 @@ curves Smooth Làm mượt Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel +# History panel › History Options… (#2966) + Automatically Create First Snapshot Tự động tạo ảnh chụp trạng thái đầu tiên + Automatically Create New Snapshot When Saving Tự động tạo ảnh chụp trạng thái mới khi lưu + Show New Snapshot Dialog by Default Mặc định hiện hộp thoại Ảnh chụp trạng thái mới Memory usage percent Tỷ lệ sử dụng bộ nhớ Show Favorite Fonts Hiển thị phông chữ yêu thích No favorite fonts yet: click the star beside a font to add it. Chưa có phông chữ yêu thích: nhấp vào ngôi sao bên cạnh phông chữ để thêm. @@ -4074,3 +4095,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Thay ảnh… The frame is locked Khung đã bị khóa The frame is unavailable Khung không khả dụng + +# Channels panel menu (#11451) + Channel Options… Tùy chọn kênh… + Specify Channels: Chỉ định kênh: + Not enough grayscale documents for this mode. Không đủ tài liệu thang xám cho chế độ này. + Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. + A picked document is no longer open. Một tài liệu đã chọn không còn mở. + A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f89abd7117..e1039a735d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -174,6 +174,7 @@ Right 右 Open Recent 打开最近文件 Clear Recent Files 清除最近文件 + Clear Recent File List 清除最近文件 Remove from Recent 从最近文件中移除 Remove {name} from Recent 从最近文件中移除 {name} Close 关闭 @@ -182,6 +183,10 @@ Restore 向下还原 Close All 关闭全部 Close Others 关闭其他 + New Document… 新建文档… + Open Document… 打开… + Consolidate All to Here 将所有窗口合并到此处 + Move to New Window 移到新窗口 Save 保存 Save As… 另存为… Save a Copy… 保存副本… @@ -234,6 +239,7 @@ Create Smart Object after Loading Layers 载入图层后创建智能对象 Reveal in Finder 在 Finder 中显示 Show in Explorer 在文件资源管理器中显示 + Reveal in Explorer 在文件资源管理器中显示 Show in Folder 在文件夹中显示 File Info… 文件信息… Print… 打印… @@ -323,6 +329,7 @@ Units & Rulers… 单位与标尺… Guides, Grid & Slices… 参考线、网格与切片… Plug-ins… 插件… + Plugins… 插件… Type… 文字… 3D… 3D… Enhanced Controls… 增强控件… @@ -482,6 +489,8 @@ Smart Object 智能对象 Convert to Smart Object 转换为智能对象 Convert to Embedded 转换为嵌入对象 + Embed Linked 嵌入链接对象 + Embed All Linked 嵌入所有链接对象 Convert to Layers 转换为图层 Convert to Linked… 转换为链接对象… Edit Contents 编辑内容 @@ -555,7 +564,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 路径操作 Align 对齐 @@ -740,6 +751,7 @@ Minimum… 最小值… Offset… 位移… Plug-ins 插件 + Plugins 插件 Proof Setup 校样设置 Proof Colors 校样颜色 Gamut Warning 色域警告 @@ -748,9 +760,9 @@ Working Magenta Plate 工作洋红版 Working Yellow Plate 工作黄色版 Working Black Plate 工作黑色版 - Working CMY Plate 工作 CMY 版 - Legacy Macintosh RGB 旧版 Macintosh RGB - Internet Standard RGB 互联网标准 RGB + Working CMY Plates 工作 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 旧版 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 互联网标准 RGB (sRGB) Monitor RGB 显示器 RGB Color Blindness — Protanopia-type 色盲 — 红色盲型 Color Blindness — Deuteranopia-type 色盲 — 绿色盲型 @@ -842,7 +854,7 @@ Consolidate All to Tabs 将所有窗口合并为选项卡 Float in Window 在窗口中浮动 Float All in Windows 使所有文档在窗口中浮动 - New Window for Document 为文档新建窗口 + New Window 为文档新建窗口 Workspace 工作区 Essentials 基本功能 Graphic and Web 图形与 Web @@ -2052,6 +2064,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 拾色器 @@ -2950,6 +2967,9 @@ curves Smooth 平滑 Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Automatically Create First Snapshot 自动创建第一幅快照 + Automatically Create New Snapshot When Saving 存储时自动创建新快照 + Show New Snapshot Dialog by Default 默认显示新建快照对话框 Memory usage percent 内存使用比例 (%) Show Favorite Fonts 显示收藏的字体 No favorite fonts yet: click the star beside a font to add it. 还没有收藏的字体:点击字体旁边的星标即可添加。 @@ -3256,3 +3276,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 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. 所选的一个文档已不再是同样大小的灰度文档。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3c1fdac34..0630f30f06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -176,6 +176,7 @@ Right 右 Open Recent 最近開啟的檔案 Clear Recent Files 清除最近檔案 + Clear Recent File List 清除最近檔案 Remove from Recent 從最近檔案中移除 Remove {name} from Recent 從最近檔案中移除 {name} Close 關閉 @@ -184,6 +185,10 @@ Restore 向下還原 Close All 全部關閉 Close Others 關閉其他 + New Document… 新增文件… + Open Document… 開啟… + Consolidate All to Here 將所有視窗合併至此處 + Move to New Window 移至新視窗 Save 儲存 Save As… 另存新檔… Save a Copy… 儲存拷貝… @@ -236,6 +241,7 @@ Create Smart Object after Loading Layers 載入圖層後建立智慧型物件 Reveal in Finder 在 Finder 中顯示 Show in Explorer 在檔案總管中顯示 + Reveal in Explorer 在檔案總管中顯示 Show in Folder 在資料夾中顯示 File Info… 檔案資訊… Print… 列印… @@ -324,6 +330,7 @@ Units & Rulers… 單位和尺標… Guides, Grid & Slices… 參考線、格點和切片… Plug-ins… 增效模組… + Plugins… 增效模組… Type… 文字… Enhanced Controls… 增強控制項… Camera Raw… Camera Raw… @@ -481,6 +488,8 @@ Smart Object 智慧型物件 Convert to Smart Object 轉換為智慧型物件 Convert to Embedded 轉換為嵌入 + Embed Linked 嵌入連結 + Embed All Linked 嵌入所有連結 Convert to Layers 轉換為圖層 Convert to Linked… 轉換為連結… Edit Contents 編輯內容 @@ -554,7 +563,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 路徑操作 Align 對齊 @@ -739,6 +750,7 @@ Minimum… 最小… Offset… 畫面錯位… Plug-ins 增效模組 + Plugins 增效模組 Proof Setup 校樣設定 Proof Colors 校樣色彩 Gamut Warning 色域警告 @@ -747,9 +759,9 @@ Working Magenta Plate 使用中的洋紅版 Working Yellow Plate 使用中的黃色版 Working Black Plate 使用中的黑色版 - Working CMY Plate 使用中的 CMY 版 - Legacy Macintosh RGB 舊版 Macintosh RGB - Internet Standard RGB 網際網路標準 RGB + Working CMY Plates 使用中的 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 舊版 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 網際網路標準 RGB (sRGB) Monitor RGB 顯示器 RGB Color Blindness — Protanopia-type 色盲 — 紅色盲型 Color Blindness — Deuteranopia-type 色盲 — 綠色盲型 @@ -841,7 +853,7 @@ Consolidate All to Tabs 全部合併至標籤頁 Float in Window 在視窗中浮動 Float All in Windows 全部在視窗中浮動 - New Window for Document 為文件新增視窗 + New Window 為文件新增視窗 Workspace 工作區 Essentials 基本功能 Graphic and Web 繪圖和網頁 @@ -2020,6 +2032,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 檢色器 @@ -2911,6 +2928,9 @@ curves Smooth 平滑 Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Automatically Create First Snapshot 自動建立第一個快照 + Automatically Create New Snapshot When Saving 儲存時自動建立新快照 + Show New Snapshot Dialog by Default 預設顯示新增快照對話框 Memory usage percent 記憶體使用比例 (%) Show Favorite Fonts 顯示我的最愛字體 No favorite fonts yet: click the star beside a font to add it. 尚無我的最愛字體:按一下字體旁的星號即可加入。 @@ -3212,3 +3232,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 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. 所選的一個文件已不再是相同大小的灰階文件。 diff --git a/crates/ui-egui/src/input/hold_keys/tests.rs b/crates/ui-egui/src/input/hold_keys/tests.rs index 8a2fbec54e..61d78d9ed3 100644 --- a/crates/ui-egui/src/input/hold_keys/tests.rs +++ b/crates/ui-egui/src/input/hold_keys/tests.rs @@ -612,7 +612,8 @@ fn cmd_alt_drag_duplicates_the_layer_then_moves_the_copy() { assert_eq!(bounds(&h, orig), Rect::new(100, 100, 160, 160), "the original stays"); assert_eq!(bounds(&h, copy), Rect::new(140, 120, 200, 180), "the copy moved"); assert_eq!(st.history.past_len(), h0 + 1, "one undo step"); - assert_eq!(st.history.undo_label(), Some(crate::move_mods::DUPLICATE_MOVE_LABEL)); + // A drag that copies a layer is one "Move" step, as in Photoshop (#11383). + assert_eq!(st.history.undo_label(), Some(tl!("Move"))); assert_eq!(h.state().ui.tool, tool); } } diff --git a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs index 633bb89ca8..ce7e3fcc39 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs @@ -386,6 +386,8 @@ fn alternate_keys_run_their_command() { ("Cmd+Shift+I", "select.inverse"), ("Cmd++", "view.zoomIn"), ("Shift+Backspace", "edit.fill"), + ("Cmd+Alt+0", "view.actualPixels"), + ("F9", "window.panel.actions"), ] { press(&mut h, key); let log = take_log(&h.ctx); diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 26dfa5e96f..b0304b8237 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -708,6 +708,11 @@ fn handle_pen_and_work_path_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f crate::vector_ui::pen_end(app); return true; } + // A shape box handle drag: Esc drops it, the shapes keep their size (#11107). + if app.ui.shape_box.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + crate::shape_bounds_box::cancel(app); + return true; + } // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in // Photoshop (#5702), never Edit › Clear on the pixels under it. if focus != crate::shortcut_dispatch::Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { @@ -820,8 +825,12 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f return true; } // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only - // makes the next one, so Esc goes on to leave full screen (#7315). - let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + // makes the next one, so Esc goes on to leave full screen (#7315). A gesture on it is still cancelled (#9275). + let untouched = app.ui.view.screen_mode != "standard" + && app.ui.polygon.is_empty() + && app.crop.default_frame + && app.crop.drag.is_none() + && !crate::crop_straighten::mode(app); if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); @@ -905,12 +914,18 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { let before = app.ui.tool; - let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); + let mut group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); + // Shift+key (the cycle and the fallback to the group's first tool) uses the toolbar + // flyout's order; plain keys keep the `Tool::ALL` order. + if cycle_shift { + group.sort_by_key(|x| crate::panels::toolbar::toolbar_position(*x)); + } app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), }; + crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); crate::spring_tools::pressed(app, t.key(), before, plain, now); diff --git a/crates/ui-egui/src/input/stylus.rs b/crates/ui-egui/src/input/stylus.rs index 73191e4ebd..b6725af5ea 100644 --- a/crates/ui-egui/src/input/stylus.rs +++ b/crates/ui-egui/src/input/stylus.rs @@ -336,6 +336,8 @@ impl Stylus { Some(true) if app.ui.tool != Tool::Eraser => { app.stylus.tool_before_eraser = Some(app.ui.tool); app.ui.tool = Tool::Eraser; + // A tool switch like any other: Add New Artboard's placing mode ends (#11386). + crate::tool_memory::ended_by_tool(app); true } Some(false) => match app.stylus.tool_before_eraser.take() { diff --git a/crates/ui-egui/src/input/stylus/tests.rs b/crates/ui-egui/src/input/stylus/tests.rs index f5c872f4af..8e1a71dfa4 100644 --- a/crates/ui-egui/src/input/stylus/tests.rs +++ b/crates/ui-egui/src/input/stylus/tests.rs @@ -350,3 +350,22 @@ fn eraser_end_switches_tools_once_and_never_mid_drag() { assert!(!Stylus::sync_eraser_tool(&mut app)); assert_eq!(app.ui.tool, Tool::Eraser); } + +/// The pen's eraser end is a tool switch like any other: Add New Artboard's placing mode ends +/// (#11386), and coming back to the Artboard tool on the tip leaves it off. +#[test] +fn the_eraser_end_ends_the_artboard_placing_mode() { + use crate::state::Tool; + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", serde_json::json!({"width": 60, "height": 60})).unwrap(); + app.ui.tool = Tool::Artboard; + app.ui.artboard_tool.adding = true; + let pen = |eraser| Some(PenSample { pressure: 0.5, eraser, ..Default::default() }); + app.stylus.feed.set(pen(true)); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert!(!app.ui.artboard_tool.adding, "the eraser end ends the mode"); + app.stylus.feed.set(pen(false)); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Artboard); + assert!(!app.ui.artboard_tool.adding); +} diff --git a/crates/ui-egui/src/layers_empty_area_menu_tests.rs b/crates/ui-egui/src/layers_empty_area_menu_tests.rs new file mode 100644 index 0000000000..10b0c52212 --- /dev/null +++ b/crates/ui-egui/src/layers_empty_area_menu_tests.rs @@ -0,0 +1,184 @@ +//! #11404: a right-click on the empty area below the last layer row opens the thumbnail size and +//! clipping choices; a left click there still deselects, and a layer row keeps its own menu. + +use egui::{PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::LayerThumbnailSize; + +const SIZES: [&str; 4] = ["No Thumbnails", "Small Thumbnails", "Medium Thumbnails", "Large Thumbnails"]; +const CLIPS: [&str; 2] = ["Clip Thumbnails to Layer Bounds", "Clip Thumbnails to Document Bounds"]; + +fn panel(open_document: bool) -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + if open_document { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(6); + h +} + +/// A point in the Layers panel well below its last row (above the panel's bottom buttons). +fn empty_point(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + let button = h.get_by_label("Create a new layer").rect(); + pos2(button.center().x - 20.0, button.top() - 80.0) +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, button: PointerButton) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +fn open_menu(h: &mut Harness<'static, PhotocraftApp>) { + let p = empty_point(h); + click(h, p, PointerButton::Secondary); +} + +fn pick(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(4); +} + +fn menu_open(h: &Harness<'static, PhotocraftApp>) -> bool { + SIZES.iter().chain(CLIPS.iter()).any(|l| h.query_by_label(l).is_some()) +} + +fn checked(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +fn size(h: &Harness<'static, PhotocraftApp>) -> LayerThumbnailSize { + h.state().ui.layers_panel_options.thumbnail_size +} + +fn clip(h: &Harness<'static, PhotocraftApp>) -> bool { + h.state().session.prefs().interface.clip_thumbnails_to_layer_bounds +} + +#[test] +fn the_menu_lists_the_six_entries_in_order_with_a_separator() { + let mut h = panel(true); + open_menu(&mut h); + let ys: Vec = SIZES.iter().chain(CLIPS.iter()).map(|l| h.get_by_label(l).rect().top()).collect(); + assert_eq!(ys.len(), 6); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "entries top to bottom in the issue's order: {ys:?}"); + let step = ys[1] - ys[0]; + assert!(ys[4] - ys[3] > step + 1.0, "the separator sits between Large Thumbnails and the clip entries: {ys:?}"); +} + +#[test] +fn the_current_size_and_clip_choice_are_the_ones_checked() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": true})).unwrap(); + h.run_steps(2); + open_menu(&mut h); + assert!(menu_open(&h)); + assert!(checked(&h, "Large Thumbnails"), "Large is the current size"); + for other in ["No Thumbnails", "Small Thumbnails", "Medium Thumbnails"] { + assert!(!checked(&h, other), "{other}"); + } + assert!(checked(&h, "Clip Thumbnails to Layer Bounds")); + assert!(!checked(&h, "Clip Thumbnails to Document Bounds")); + + pick(&mut h, "Clip Thumbnails to Document Bounds"); + assert!(!clip(&h)); + open_menu(&mut h); + assert!(checked(&h, "Clip Thumbnails to Document Bounds")); + assert!(!checked(&h, "Clip Thumbnails to Layer Bounds")); +} + +#[test] +fn each_size_pick_sets_the_panel_option_and_saves_it() { + for (label, want) in [ + ("No Thumbnails", LayerThumbnailSize::None), + ("Small Thumbnails", LayerThumbnailSize::Small), + ("Large Thumbnails", LayerThumbnailSize::Large), + ("Medium Thumbnails", LayerThumbnailSize::Medium), + ] { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = + if want == LayerThumbnailSize::Large { LayerThumbnailSize::Small } else { LayerThumbnailSize::Large }; + h.run_steps(2); + open_menu(&mut h); + pick(&mut h, label); + assert_eq!(size(&h), want, "{label}"); + assert!(!menu_open(&h), "{label}: the menu closes"); + let saved = h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + let saved = saved.expect("the choice is saved as Panel Options saves it"); + assert_eq!(saved, serde_json::to_value(&h.state().ui.layers_panel_options).unwrap(), "{label}"); + } +} + +#[test] +fn each_clip_pick_sets_the_preference() { + let mut h = panel(true); + assert!(!clip(&h)); + open_menu(&mut h); + pick(&mut h, "Clip Thumbnails to Layer Bounds"); + assert!(clip(&h)); + assert_eq!(crate::panels::layers_panel_ui::thumbnail_contents(h.state()), crate::state::LayerThumbnailContents::LayerBounds, "Panel Options agrees"); + open_menu(&mut h); + pick(&mut h, "Clip Thumbnails to Document Bounds"); + assert!(!clip(&h)); + assert_eq!(crate::panels::layers_panel_ui::thumbnail_contents(h.state()), crate::state::LayerThumbnailContents::EntireDocument); +} + +#[test] +fn a_left_click_on_the_empty_area_still_deselects_and_a_right_click_does_not() { + let mut h = panel(true); + let selected = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().selected_layers().len(); + h.run_steps(3); + let before = selected(&h); + assert!(before > 0); + open_menu(&mut h); + assert!(menu_open(&h)); + assert_eq!(selected(&h), before, "a right-click does not deselect"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let p = empty_point(&h); + click(&mut h, p, PointerButton::Primary); + assert_eq!(selected(&h), 0, "a left click deselects every layer"); +} + +#[test] +fn with_no_document_nothing_opens() { + let mut h = panel(false); + let p = pos2(1100.0, 400.0); + click(&mut h, p, PointerButton::Secondary); + assert!(!menu_open(&h)); +} + +#[test] +fn a_right_click_on_a_layer_row_still_opens_the_layer_menu() { + let mut h = panel(true); + let row = h.get_all_by_label("Layer 1").next().expect("the new layer's row").rect(); + click(&mut h, row.center(), PointerButton::Secondary); + assert!(h.query_by_label("Duplicate Layer…").is_some() || h.query_by_label("Duplicate Layer").is_some(), "the layer menu opened"); + assert!(h.query_by_label("No Thumbnails").is_none(), "not the empty-area menu"); +} + +/// A filter that matches no layer draws no row: the whole list is empty space, and its +/// right-click still opens the menu. +#[test] +fn with_no_row_shown_by_the_filter_the_right_click_still_opens_the_menu() { + let mut h = panel(true); + h.state_mut().ui.layer_filter_by = crate::layer_filter_ui::FilterBy::Name; + h.state_mut().ui.layer_filter_name = "no layer has this name".into(); + h.run_steps(4); + assert!(h.query_by_label("Layer 1").is_none(), "the filter hides every row"); + open_menu(&mut h); + assert!(menu_open(&h), "the empty list's right-click opens the menu"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7a5f226a5..25d77a32e2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -306,6 +306,7 @@ pub use tools::perspective_crop_ui; pub use tools::perspective_ui; pub use tools::puppet_ui; pub use tools::retouch_ui; +pub use tools::shape_bounds_box; use tools::shape_gradient_ui; use tools::shape_options; use tools::shape_paint_ui; @@ -757,6 +758,11 @@ impl SyntheticTarget { } } +#[cfg(test)] +mod artboard_add_mode_tests; +#[cfg(test)] +mod shape_bounds_box_tests; + #[cfg(test)] mod lifecycle_tests; @@ -793,11 +799,19 @@ mod layer_shortcut_tests; #[cfg(test)] mod live_retouch_tests; #[cfg(test)] +mod lock_layers_shortcut_tests; +#[cfg(test)] mod pencil_tests; #[cfg(test)] +mod smart_objects_menu_tests; +#[cfg(test)] mod tool_options_theme_tests; #[cfg(test)] +mod tool_shift_cycle_order_tests; +#[cfg(test)] mod tool_shortcut_tests; +#[cfg(test)] +mod toolbar_group_order_tests; #[cfg(test)] mod pixel_aspect_tests; @@ -839,6 +853,11 @@ mod save_identity_tests; #[cfg(test)] mod move_auto_select_tests; +#[cfg(test)] +mod move_history_names_tests; +#[cfg(test)] +mod move_shift_diagonal_tests; + #[cfg(test)] mod new_group_button_tests; @@ -881,6 +900,9 @@ mod blend_dropdown_wheel_tests; #[cfg(test)] mod paths_scroll_tests; +#[cfg(test)] +mod blend_dropdown_groups_tests; + #[cfg(test)] mod options_bar_mode_wheel_tests; @@ -890,9 +912,14 @@ mod field_wheel_app_tests; #[cfg(test)] mod dropdown_press_tests; +#[cfg(test)] +mod marquee_press_mods_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod single_line_marquee_move_tests; + #[cfg(test)] mod guide_cursor_tests; @@ -914,10 +941,14 @@ mod stamp_tests; #[cfg(test)] mod alt_click_tests; #[cfg(test)] +mod artboard_first_board_tests; +#[cfg(test)] mod status_bar_tests; #[cfg(test)] mod stroke_timing_tests; +#[cfg(test)] +mod polygon_lasso_step_snap_tests; #[cfg(test)] mod polygon_lasso_tests; @@ -927,6 +958,8 @@ mod window_frame_tests; #[cfg(test)] mod browse_field_tests; #[cfg(test)] +mod export_quality_tests; +#[cfg(test)] mod options_hit_area_tests; #[cfg(test)] mod select_and_mask_button_tests; @@ -934,5 +967,18 @@ mod select_and_mask_button_tests; #[cfg(test)] mod saved_path_edit_tests; +#[cfg(test)] +mod menu_item_labels_tests; #[cfg(test)] mod pen_path_ownership_tests; + +#[cfg(test)] +mod doc_tab_menu_tests; +#[cfg(test)] +mod layers_empty_area_menu_tests; +#[cfg(test)] +mod preferences_submenu_tests; +#[cfg(test)] +mod selection_move_step_tests; +#[cfg(test)] +mod view_guides_submenu_tests; diff --git a/crates/ui-egui/src/lock_layers_shortcut_tests.rs b/crates/ui-egui/src/lock_layers_shortcut_tests.rs new file mode 100644 index 0000000000..8d5f215e19 --- /dev/null +++ b/crates/ui-egui/src/lock_layers_shortcut_tests.rs @@ -0,0 +1,143 @@ +//! Ctrl+/ (Cmd+/ on the Mac) opens Layer > Lock Layers..., and the Layer menu and the Layers panel +//! menu show the command's current key. Plain / still toggles Lock Transparent Pixels (#11397). + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn locks(app: &PhotocraftApp) -> photocraft_doc::Locks { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().locks +} + +fn set_shortcut(app: &mut PhotocraftApp, value: &str) { + app.session.prefs.edit(|prefs| prefs.shortcuts.insert("layer.lockLayers".into(), value.into())); +} + +#[test] +fn lock_layers_defaults_to_command_slash_in_the_engine_and_the_menu() { + assert_eq!(photocraft_engine::commands::find("layer.lockLayers").unwrap().shortcut, Some("Cmd+/")); + let row = crate::menu_catalog::CATALOG.iter().find(|c| c.3 == "layer.lockLayers").unwrap(); + assert_eq!(row.2, Some("Cmd+/")); + assert_eq!(crate::shortcuts::default_shortcut("layer.lockLayers").as_deref(), Some("Cmd+/")); +} + +#[test] +fn command_slash_runs_what_the_menu_item_runs() { + let mut via_key = app(); + press(&mut via_key, Key::Slash, Modifiers::COMMAND); + let mut via_menu = app(); + crate::menus::invoke(&mut via_menu, &egui::Context::default(), "layer.lockLayers", json!({})).unwrap(); + assert_eq!(locks(&via_key), locks(&via_menu)); + assert!(locks(&via_key).all, "Lock Layers... with no choices locks the layer"); +} + +#[test] +fn command_slash_does_nothing_when_the_menu_item_is_greyed() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(!crate::menus::is_enabled(&app, "layer.lockLayers")); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(app.session.active().is_none()); + assert!(app.ui.layers_panel_dialog.is_none()); +} + +/// Every text the Layers panel menu paints in one frame (the row labels and their shortcut texts). +fn painted_text(app: &mut PhotocraftApp) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput::default()); + let mut ui = egui::Ui::new( + ctx.clone(), + egui::Id::unique("lock-layers-panel-menu"), + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 900.0))), + ); + crate::panels::layer_row_ui::panel_menu(app, &mut ui); + // The texture deltas go nowhere in a test: clear them, or epaint's drop check complains. + let mut pass = ctx.end_pass(); + pass.textures_delta.clear(); + let mut out = Vec::new(); + for clipped in pass.shapes { + collect(&clipped.shape, &mut out); + } + out +} + +#[test] +fn panel_menu_row_shows_the_current_key_and_follows_a_change() { + let mut app = app(); + let default_key = crate::shortcuts::pretty("Cmd+/"); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), Some(default_key.clone())); + // The panel menu paints the key beside Lock Layers… + let texts = painted_text(&mut app); + assert!(texts.iter().any(|t| t == "Lock Layers…"), "{texts:?}"); + assert!(texts.contains(&default_key), "the default key is painted: {texts:?}"); + // A changed key is painted instead of the default. + set_shortcut(&mut app, "Cmd+Shift+L"); + let changed = crate::shortcuts::pretty("Cmd+Shift+L"); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), Some(changed.clone())); + let texts = painted_text(&mut app); + assert!(texts.contains(&changed), "{texts:?}"); + assert!(!texts.contains(&default_key), "the old key is gone: {texts:?}"); + // A removed key paints no key at all. + let mut app = app_with_removed_key(); + let texts = painted_text(&mut app); + assert!(texts.iter().any(|t| t == "Lock Layers…"), "{texts:?}"); + assert!(!texts.contains(&default_key) && !texts.contains(&changed), "{texts:?}"); +} + +#[test] +fn the_key_can_be_changed_and_removed() { + let mut app = app(); + set_shortcut(&mut app, "Cmd+Shift+L"); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(!locks(&app).all, "the old key no longer runs the command"); + press(&mut app, Key::L, Modifiers::COMMAND | Modifiers::SHIFT); + assert!(locks(&app).all, "the new key does"); + let mut app = app_with_removed_key(); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(!locks(&app).all); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), None); +} + +fn app_with_removed_key() -> PhotocraftApp { + let mut app = app(); + set_shortcut(&mut app, ""); + app +} + +#[test] +fn plain_slash_toggles_lock_transparency_and_command_slash_does_not() { + let mut app = app(); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(locks(&app).transparency && !locks(&app).all); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(!locks(&app).transparency); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(locks(&app).all); + assert!(!locks(&app).transparency, "Ctrl+/ leaves Lock Transparent Pixels alone"); +} diff --git a/crates/ui-egui/src/marquee_press_mods_tests.rs b/crates/ui-egui/src/marquee_press_mods_tests.rs new file mode 100644 index 0000000000..235dc2dfe4 --- /dev/null +++ b/crates/ui-egui/src/marquee_press_mods_tests.rs @@ -0,0 +1,163 @@ +//! A Shift or Alt held from the start of a Rectangular or Elliptical Marquee drag picks the +//! selection mode only when the document already has a selection (#11389). Without one the same +//! keys constrain the shape from the first move, as in the reference application: Shift squares to +//! the drag's shorter side, Alt draws from the centre. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + harness_with_step_dt(tool, 0.25) +} + +fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_step_dt(step_dt).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // 100 %: one document pixel per point, so drags land on whole pixels. + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [400.0, 300.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn view_xform(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + view_xform(h).to_screen(x, y) +} + +fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); + h.run_steps(1); +} + +/// Move the pointer to document point `(x, y)` in a few steps. +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); +} + +fn press_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(h, p, true, m); + // Past egui's click distance, so the drag starts at the press point. + move_to(h, x + 8.0, y + 6.0); +} + +fn release_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + move_to(h, x, y); + button(h, screen(h, x, y), false, m); + h.run_steps(2); +} + +fn selection(h: &Harness<'static, PhotocraftApp>) -> Rect { + h.state().session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()) +} + +/// The two-row readout shows `W: {w} px` and `H: {ht} px` (Photoshop's format). +fn readout(h: &Harness<'static, PhotocraftApp>, w: i32, ht: i32) -> bool { + let labels = h.query_by_label("W:").is_some() && h.query_by_label("H:").is_some(); + let count = |v: i32| h.query_all_by_label(&format!("{v} px")).count(); + let values = if w == ht { count(w) == 2 } else { count(w) == 1 && count(ht) == 1 }; + labels && values && h.query_all_by_label_contains(" px").count() == 2 +} + +/// Drag a marquee from `from` to `to` with `m` held from before the press to after the release. +fn drag_held(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, from: [f32; 2], to: [f32; 2]) { + mods(h, m); + press_at(h, from[0], from[1], m); + move_to(h, to[0], to[1]); + release_at(h, to[0], to[1], m); + mods(h, Modifiers::NONE); +} + +#[test] +fn without_a_selection_keys_held_from_the_press_constrain_the_shape() { + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let cases = [ + // ⇧ from the press: a 200 × 100 drag selects a 100 × 100 square (the shorter side). + (Modifiers::SHIFT, [100.0, 100.0], [300.0, 200.0], Rect::new(100, 100, 200, 200)), + // ⌥ from the press: drawn from the centre. + (Modifiers::ALT, [400.0, 300.0], [500.0, 350.0], Rect::new(300, 250, 500, 350)), + // Both: a square from the centre, half extent the shorter drag. + (shift_alt, [400.0, 300.0], [500.0, 350.0], Rect::new(350, 250, 450, 350)), + ]; + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (m, from, to, want) in cases { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + drag_held(&mut h, m, from, to); + assert_eq!(selection(&h), want, "{tool:?} with {m:?} held from the press"); + } + } +} + +#[test] +fn the_live_readout_shows_the_constrained_shape_from_the_first_move() { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = false; + mods(&mut h, Modifiers::SHIFT); + press_at(&mut h, 100.0, 100.0, Modifiers::SHIFT); + move_to(&mut h, 300.0, 200.0); + assert!(readout(&h, 100, 100), "the readout agrees with what the release selects"); + release_at(&mut h, 300.0, 200.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(100, 100, 200, 200)); +} + +#[test] +fn with_a_selection_keys_held_from_the_press_still_pick_the_mode() { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + // ⇧ adds, and the added rectangle is as drawn, not squared: the union reaches 600. + drag_held(&mut h, Modifiers::SHIFT, [400.0, 100.0], [600.0, 150.0]); + assert_eq!(selection(&h), Rect::new(100, 100, 600, 300)); + let st = h.state().session.active().unwrap(); + let sel = st.doc.selection.as_ref().unwrap(); + assert!(sel.sample_channel(150, 150, 0) > 0.99 && sel.sample_channel(550, 125, 0) > 0.99 && sel.sample_channel(550, 250, 0) < 0.01); +} diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 328fe24fc5..179c51fd00 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -149,6 +149,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Edit"], "Toolbar…", None, "edit.toolbar"), (&["Edit"], "---", None, "---"), (&["Edit", "Preferences"], "Settings…", Some("Cmd+K"), "edit.preferences.general"), + (&["Edit", "Preferences"], "---", None, "---"), (&["Edit", "Preferences"], "AI Integrations…", None, "edit.preferences.integrations"), (&["Edit", "Preferences"], "Interface…", None, "edit.preferences.interface"), (&["Edit", "Preferences"], "Workspace…", None, "edit.preferences.workspace"), @@ -162,10 +163,11 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Edit", "Preferences"], "Transparency & Gamut…", None, "edit.preferences.transparencyAndGamut"), (&["Edit", "Preferences"], "Units & Rulers…", None, "edit.preferences.unitsAndRulers"), (&["Edit", "Preferences"], "Guides, Grid & Slices…", None, "edit.preferences.guidesGridAndSlices"), - (&["Edit", "Preferences"], "Plug-ins…", None, "edit.preferences.plugIns"), + (&["Edit", "Preferences"], "Plugins…", None, "edit.preferences.plugIns"), (&["Edit", "Preferences"], "Type…", None, "edit.preferences.type"), (&["Edit", "Preferences"], "Enhanced Controls…", None, "edit.preferences.enhancedControls"), - (&["Edit", "Preferences"], "Raw Defaults…", None, "edit.preferences.rawDefaults"), + (&["Edit", "Preferences"], "---", None, "---"), + (&["Edit", "Preferences"], "Camera Raw…", None, "edit.preferences.rawDefaults"), (&["Image", "Mode"], "Bitmap", None, "image.mode.bitmap"), (&["Image", "Mode"], "Grayscale", None, "image.mode.grayscale"), (&["Image", "Mode"], "Duotone", None, "image.mode.duotone"), @@ -350,8 +352,9 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Smart Objects"], "Replace Contents…", None, "layer.smartObjects.replaceContents"), (&["Layer", "Smart Objects"], "Export Contents…", None, "layer.smartObjects.exportContents"), (&["Layer", "Smart Objects"], "---", None, "---"), + (&["Layer", "Smart Objects"], "Embed Linked", None, "layer.smartObjects.convertToEmbedded"), + (&["Layer", "Smart Objects"], "Embed All Linked", None, "layer.smartObjects.embedAllLinked"), (&["Layer", "Smart Objects"], "Convert to Linked…", None, "layer.smartObjects.convertToLinked"), - (&["Layer", "Smart Objects"], "Convert to Embedded", None, "layer.smartObjects.convertToEmbedded"), (&["Layer", "Smart Objects"], "Convert to Layers", None, "layer.smartObjects.convertToLayers"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects", "Stack Mode"], "Entropy", None, "layer.smartObjects.stackMode.entropy"), @@ -367,10 +370,6 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Smart Objects", "Stack Mode"], "Variance", None, "layer.smartObjects.stackMode.variance"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects"], "Rasterize", None, "layer.smartObjects.rasterize"), - (&["Layer", "Smart Objects"], "---", None, "---"), - (&["Layer", "Smart Objects"], "Warp", None, "layer.smartObjects.warp"), - (&["Layer", "Smart Objects"], "Perspective Warp", None, "layer.smartObjects.perspectiveWarp"), - (&["Layer", "Smart Objects"], "Puppet Warp", None, "layer.smartObjects.puppetWarp"), (&["Layer", "Video Layers"], "New Blank Video Layer", None, "layer.videoLayers.newBlankVideoLayer"), (&["Layer", "Video Layers"], "New Video Layer from File…", None, "layer.videoLayers.newVideoLayerFromFile"), (&["Layer", "Video Layers"], "---", None, "---"), @@ -413,8 +412,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Arrange"], "Reverse", None, "layer.arrange.reverse"), (&["Layer", "Combine Shapes"], "Unite Shapes", None, "layer.combineShapes.unite"), (&["Layer", "Combine Shapes"], "Subtract Front Shape", None, "layer.combineShapes.subtractFrontShape"), - (&["Layer", "Combine Shapes"], "Intersect Shape Areas", None, "layer.combineShapes.intersectShapeAreas"), - (&["Layer", "Combine Shapes"], "Exclude Overlapping Shapes", None, "layer.combineShapes.excludeOverlappingShapes"), + (&["Layer", "Combine Shapes"], "Unite Shapes at Overlap", None, "layer.combineShapes.intersectShapeAreas"), + (&["Layer", "Combine Shapes"], "Subtract Shapes at Overlap", None, "layer.combineShapes.excludeOverlappingShapes"), (&["Layer", "Combine Shapes"], "---", None, "---"), (&["Layer", "Combine Shapes"], "Merge Shape Components", None, "layer.combineShapes.mergeShapeComponents"), (&["Layer", "Align"], "Top Edges", None, "layer.align.topEdges"), @@ -435,7 +434,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Distribute"], "Horizontally", None, "layer.distribute.horizontally"), (&["Layer", "Distribute"], "Vertically", None, "layer.distribute.vertically"), (&["Layer"], "---", None, "---"), - (&["Layer"], "Lock Layers…", None, "layer.lockLayers"), + (&["Layer"], "Lock Layers…", Some("Cmd+/"), "layer.lockLayers"), (&["Layer"], "---", None, "---"), (&["Layer"], "Link Layers", None, "layer.linkLayers"), (&["Layer"], "Select Linked Layers", None, "layer.selectLinkedLayers"), @@ -650,10 +649,10 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["View", "Proof Setup"], "Working Magenta Plate", None, "view.proofSetup.workingMagentaPlate"), (&["View", "Proof Setup"], "Working Yellow Plate", None, "view.proofSetup.workingYellowPlate"), (&["View", "Proof Setup"], "Working Black Plate", None, "view.proofSetup.workingBlackPlate"), - (&["View", "Proof Setup"], "Working CMY Plate", None, "view.proofSetup.workingCmyPlate"), + (&["View", "Proof Setup"], "Working CMY Plates", None, "view.proofSetup.workingCmyPlate"), (&["View", "Proof Setup"], "---", None, "---"), - (&["View", "Proof Setup"], "Legacy Macintosh RGB", None, "view.proofSetup.legacyMacintoshRgb"), - (&["View", "Proof Setup"], "Internet Standard RGB", None, "view.proofSetup.internetStandardRgb"), + (&["View", "Proof Setup"], "Legacy Macintosh RGB (Gamma 1.8)", None, "view.proofSetup.legacyMacintoshRgb"), + (&["View", "Proof Setup"], "Internet Standard RGB (sRGB)", None, "view.proofSetup.internetStandardRgb"), (&["View", "Proof Setup"], "Monitor RGB", None, "view.proofSetup.monitorRgb"), (&["View", "Proof Setup"], "---", None, "---"), (&["View", "Proof Setup"], "Color Blindness — Protanopia-type", None, "view.proofSetup.colorBlindnessProtanopia"), @@ -720,13 +719,15 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["View", "Snap To"], "---", None, "---"), (&["View", "Snap To"], "All", None, "view.snapTo.all"), (&["View"], "---", None, "---"), - (&["View"], "Lock Guides", Some("Cmd+Alt+;"), "view.lockGuides"), - (&["View"], "Clear Guides", None, "view.clearGuides"), - (&["View"], "Clear Selected Artboard Guides", None, "view.clearSelectedArtboardGuides"), - (&["View"], "Clear Canvas Guides", None, "view.clearCanvasGuides"), - (&["View"], "New Guide…", None, "view.newGuide"), - (&["View"], "New Guide Layout…", None, "view.newGuideLayout"), - (&["View"], "New Guides From Shape", None, "view.newGuidesFromShape"), + (&["View", "Guides"], "Edit Selected Guides", None, "view.editSelectedGuides"), + (&["View", "Guides"], "Lock Guides", Some("Cmd+Alt+;"), "view.lockGuides"), + (&["View", "Guides"], "Clear Guides", None, "view.clearGuides"), + (&["View", "Guides"], "Clear Selected Guides", None, "view.clearSelectedGuides"), + (&["View", "Guides"], "Clear Selected Artboard Guides", None, "view.clearSelectedArtboardGuides"), + (&["View", "Guides"], "Clear Canvas Guides", None, "view.clearCanvasGuides"), + (&["View", "Guides"], "New Guide…", None, "view.newGuide"), + (&["View", "Guides"], "New Guide Layout…", None, "view.newGuideLayout"), + (&["View", "Guides"], "New Guides From Shape", None, "view.newGuidesFromShape"), (&["View"], "---", None, "---"), (&["View"], "Lock Slices", None, "view.lockSlices"), (&["View"], "Clear Slices", None, "view.clearSlices"), @@ -750,7 +751,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Window", "Arrange"], "Float in Window", None, "window.arrange.floatInWindow"), (&["Window", "Arrange"], "Float All in Windows", None, "window.arrange.floatAllInWindows"), (&["Window", "Arrange"], "---", None, "---"), - (&["Window", "Arrange"], "New Window for Document", None, "window.arrange.newWindowForDocument"), + (&["Window", "Arrange"], "New Window", None, "window.arrange.newWindowForDocument"), (&["Window", "Arrange"], "---", None, "---"), (&["Window", "Arrange"], "Match Zoom", None, "window.arrange.matchZoom"), (&["Window", "Arrange"], "Match Location", None, "window.arrange.matchLocation"), diff --git a/crates/ui-egui/src/menu_item_labels_tests.rs b/crates/ui-egui/src/menu_item_labels_tests.rs new file mode 100644 index 0000000000..6e09df739f --- /dev/null +++ b/crates/ui-egui/src/menu_item_labels_tests.rs @@ -0,0 +1,82 @@ +//! #11399: the Combine Shapes, Open Recent, Arrange and Proof Setup items carry the reference +//! application's names, in the live menu tree and in the engine's command labels. + +use crate::PhotocraftApp; +use crate::menus::menu_items; + +/// (command id, label the live menu must show). +const LABELS: [(&str, &str); 7] = [ + ("layer.combineShapes.intersectShapeAreas", "Unite Shapes at Overlap"), + ("layer.combineShapes.excludeOverlappingShapes", "Subtract Shapes at Overlap"), + ("file.clearRecent", "Clear Recent File List"), + ("window.arrange.newWindowForDocument", "New Window"), + ("view.proofSetup.workingCmyPlate", "Working CMY Plates"), + ("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB (Gamma 1.8)"), + ("view.proofSetup.internetStandardRgb", "Internet Standard RGB (sRGB)"), +]; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) +} + +#[test] +fn live_menu_shows_the_reference_names() { + let app = app(); + let items = menu_items(&app); + for (id, label) in LABELS { + let item = items.iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} is in the menu tree")); + assert_eq!(item.label, label, "{id}"); + } +} + +#[test] +fn open_recent_clear_item_is_labelled_with_and_without_recent_files() { + let mut app = app(); + app.ui.recent_files = vec!["/tmp/a.png".into()]; + let items = menu_items(&app); + let clear = items.iter().find(|i| i.id == "file.clearRecent").expect("clear item"); + assert_eq!(clear.label, "Clear Recent File List"); + assert!(clear.enabled); + assert_eq!(clear.path, vec!["File".to_string(), "Open Recent".to_string()]); + // With nothing to clear the item stays in the submenu, greyed. + app.ui.recent_files.clear(); + let items = menu_items(&app); + let clear = items.iter().find(|i| i.id == "file.clearRecent").expect("clear item"); + assert_eq!(clear.label, "Clear Recent File List"); + assert!(!clear.enabled); +} + +#[test] +fn engine_command_labels_match_the_menu() { + let specs = photocraft_engine::cmd::command_specs(); + // The two Combine Shapes and the three Proof Setup commands have an engine command under the + // same id as the menu row. + for (id, label) in LABELS { + if let Some(spec) = specs.iter().find(|s| s.id == id) { + assert_eq!(spec.label, label, "engine label of {id}"); + } + } + let engine_ids = [ + "layer.combineShapes.intersectShapeAreas", + "layer.combineShapes.excludeOverlappingShapes", + "view.proofSetup.workingCmyPlate", + "view.proofSetup.legacyMacintoshRgb", + ]; + for id in engine_ids { + assert!(specs.iter().any(|s| s.id == id), "{id} is an engine command"); + } + // The shell command for the extra window is registered under its own id. + let shell = photocraft_engine::shell_commands::SHELL_COMMANDS.iter().find(|c| c.0 == "window.newWindowForDocument").expect("shell command"); + assert_eq!(shell.1, "New Window"); +} + +#[test] +fn history_step_names_and_other_labels_are_unchanged() { + // The Home screen keeps its own button label (#11399 "not measured"). + let src = include_str!("home_recent_ui.rs"); + assert!(src.contains("tl!(\"Clear Recent Files\")")); + // Merge Shape Components stays in Combine Shapes. + let app = app(); + let items = menu_items(&app); + assert!(items.iter().any(|i| i.id == "layer.combineShapes.mergeShapeComponents")); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 01195598c0..95a57d9588 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -203,7 +203,7 @@ mod reveal_label_tests { let expected = if cfg!(target_os = "macos") { "Reveal in Finder" } else if cfg!(windows) { - "Show in Explorer" + "Reveal in Explorer" } else { "Show in Folder" }; diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index e1f52d934c..b7e8e812d7 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -415,20 +415,36 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::new_group_ui::open(app, id)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while - // in Quick Mask (with params: the engine directly). + // channel (also in Quick Mask mode, where its row is the selected one), Quick Mask Options + // in Quick Mask otherwise, or Layer Mask Display Options on a targeted layer mask (with + // params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { - _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), + _ if st.doc.quick_mask.is_some() => json!("quickMask"), _ if mask => json!("mask"), _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) } + // Channels › New Channel… without params: its dialog (with params: the engine). The + // panel's New button makes one at once, as Photoshop's does. + crate::channel_options_ui::NEW_CHANNEL if params.as_object().is_none_or(|o| o.is_empty()) => { + let dialog = crate::channel_options_ui::open_new_channel(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } + // Channels › Merge Channels… without params: its dialog, which picks the mode and the + // documents (with params: the engine). + crate::dialogs::merge_channels_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) { + return Err(why); + } + let dialog = crate::dialogs::merge_channels_ui::open(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Channels › New Spot Channel… without params: its dialog (with params: the engine). crate::channel_options_ui::NEW_SPOT if params.as_object().is_none_or(|o| o.is_empty()) => { let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; @@ -610,6 +626,10 @@ fn type_command_target(app: &PhotocraftApp) -> Option { fn invoke_view_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { match id { "view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => Some(app.run(id, params.clone())), + "view.editSelectedGuides" => Some(crate::new_guide_ui::open_edit(app).map(|_| Value::Null)), + "view.clearSelectedGuides" => { + Some(crate::guide_select::delete_selected(app).and_then(|done| if done { Ok(Value::Null) } else { Err("No guide is selected".to_string()) })) + } "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => { let extras = &mut app.ui.extras; let slot = match id { diff --git a/crates/ui-egui/src/menus/items.rs b/crates/ui-egui/src/menus/items.rs index 37944cca5f..fabce59420 100644 --- a/crates/ui-egui/src/menus/items.rs +++ b/crates/ui-egui/src/menus/items.rs @@ -154,7 +154,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } recent.push(MenuItem { id: "file.clearRecent".into(), - label: "Clear Recent Files".into(), + label: "Clear Recent File List".into(), path: rp.clone(), shortcut: None, enabled: !app.ui.recent_files.is_empty(), diff --git a/crates/ui-egui/src/menus/native_menu.rs b/crates/ui-egui/src/menus/native_menu.rs index 4dad63c448..88106d1304 100644 --- a/crates/ui-egui/src/menus/native_menu.rs +++ b/crates/ui-egui/src/menus/native_menu.rs @@ -761,6 +761,8 @@ pub(crate) fn state_hash(app: &PhotocraftApp, input: Option) -> u64 { let ui = &app.ui; (&ui.recent_files, &ui.workspace, ui.palette_open, ui.transform.is_some(), ui.text_edit.is_some(), ui.dialogs.len()).hash(&mut h); (app.discard.is_some(), app.camera_raw.is_some(), crate::distort_ui::modal_workspace(app)).hash(&mut h); + // Edit / Clear Selected Guides are enabled only while a guide is selected. + crate::guide_select::selected(app).hash(&mut h); serde_json::to_string(&(&ui.panels, &ui.extras, &ui.view, ui.theme, ui.tool)).unwrap_or_default().hash(&mut h); s.prefs().interface.language.hash(&mut h); h.finish() diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index c932ca092c..f6a3c55534 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -80,6 +80,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { // "Custom…" is the full Proof Setup dialog. "view.proofSetup.custom" => app.session.active().is_some(), "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true, + "view.editSelectedGuides" | "view.clearSelectedGuides" => crate::guide_select::selected(app).is_some(), "edit.cut" | "edit.copy" if app.ui.text_edit.is_some() => crate::type_tool::selected_range(app).is_some(), "edit.paste" | "select.all" if app.ui.text_edit.is_some() => true, // An image copied in another app can only be seen by reading the OS clipboard, which happens @@ -211,7 +212,7 @@ pub(crate) fn reveal_label() -> &'static str { if cfg!(target_os = "macos") { "Reveal in Finder" } else if cfg!(windows) { - "Show in Explorer" + "Reveal in Explorer" } else { "Show in Folder" } diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 57ade54a13..16cefbd87b 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -66,8 +66,10 @@ pub(super) fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: if let Value::Object(m) = fields { f.extend(m); } - // Snapshot user-facing params at open so Alt+Reset can restore them. - let orig: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + // Snapshot user-facing params at open so Alt+Reset can restore them, with the Custom + // quality rows' inactive state. + let orig: Map = + f.iter().filter(|(k, _)| !k.starts_with("__") || RESET_SHELL_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); f.insert("__formOrig".into(), Value::Object(orig)); app.ui.open_dialog(DialogKind::Command, f) } @@ -166,18 +168,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { ui.label(label(&k)); let mut custom = v.as_f64().is_some(); - let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; - ui.horizontal(|ui| { - crate::widgets::checkbox(ui, &mut custom, tl!("Custom")); - ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); - }); + let mut quality = v.as_f64().or_else(|| f.get(BATCH_QUALITY).and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, true); // None preserves the command's existing JPEG and lossless WebP defaults. // The inactive custom value is shell state, never a command parameter. - f.insert("__batchQuality".into(), json!(quality)); + f.insert(BATCH_QUALITY.into(), json!(quality)); f.insert(k, if custom { json!(quality) } else { Value::Null }); ui.end_row(); continue; } + if k == "quality" && f.contains_key(JPEG_QUALITY) { + ui.label(label(&k)); + jpeg_quality_field(ui, f); + ui.end_row(); + continue; + } let new = match &v { Value::Array(_) | Value::Object(_) | Value::Null => continue, Value::Bool(b) => { @@ -246,6 +251,42 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { camera_data(ui, f); } +/// Batch's inactive Custom quality (#3956): shell state, never a command parameter. +const BATCH_QUALITY: &str = "__batchQuality"; +/// An export-to-files dialog's Custom checkbox and its quality (#11481), kept while the format +/// is not JPEG or Custom is off: shell state, never command parameters. +const JPEG_CUSTOM: &str = "__jpegCustom"; +const JPEG_QUALITY: &str = "__jpegQuality"; +/// The shell keys Alt+Reset restores along with a form's params. +const RESET_SHELL_KEYS: [&str; 3] = [BATCH_QUALITY, JPEG_CUSTOM, JPEG_QUALITY]; + +/// A Custom checkbox and the 0..12 Quality field it enables (Batch, the exports to files). Off, +/// the command gets no quality and keeps its own defaults; `usable` false greys out both. +fn custom_quality_row(ui: &mut egui::Ui, custom: &mut bool, quality: &mut f32, usable: bool) { + ui.add_enabled_ui(usable, |ui| { + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, custom, tl!("Custom")); + ui.add_enabled_ui(*custom, |ui| crate::widgets::value_field(ui, quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); + }); + }); +} + +/// Layers, Layer Comps and Artboards to Files: the JPEG quality, sent only for a JPEG with Custom +/// on. Left alone, the export writes what the command writes with no quality (the source JPEG's +/// own quality, else the encoder default), as Batch does; a quality would also make a WebP lossy +/// (its default is lossless), and PNG, PSD, TIFF and BMP have none. +fn jpeg_quality_field(ui: &mut egui::Ui, f: &mut Map) { + let jpeg = f.get("format").and_then(Value::as_str).is_some_and(|x| x.eq_ignore_ascii_case("jpg") || x.eq_ignore_ascii_case("jpeg")); + let sent = f.get("quality").and_then(Value::as_f64); + let mut custom = sent.is_some() || f.get(JPEG_CUSTOM).and_then(Value::as_bool).unwrap_or(false); + let mut quality = sent.or_else(|| f.get(JPEG_QUALITY).and_then(Value::as_f64)).unwrap_or(10.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, jpeg); + let quality = quality.round().clamp(0.0, 12.0) as i64; + f.insert(JPEG_CUSTOM.into(), json!(custom)); + f.insert(JPEG_QUALITY.into(), json!(quality)); + f.insert("quality".into(), if jpeg && custom { json!(quality) } else { Value::Null }); +} + /// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP). fn camera_data(ui: &mut egui::Ui, f: &Map) { let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return }; @@ -428,7 +469,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "quality": null, JPEG_CUSTOM: false, + JPEG_QUALITY: 10, "visibleOnly": true, "trim": false, "__order": ["dir", "prefix", "visibleOnly", "format", "quality", "trim"]}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) } @@ -436,9 +478,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); let fields = if id == "file.export.layerCompsToFiles" { - json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "selectedOnly": false, + "__order": ["dir", "prefix", "selectedOnly", "format", "quality"]}) } else { - json!({"dir": dir, "prefix": prefix, "format": "png"}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "__order": ["dir", "prefix", "format", "quality"]}) }; dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) } diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 52df38d96c..8b80767e6f 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -764,6 +764,29 @@ fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { assert_eq!(app.ui.view.screen_mode, "standard"); } +/// #9275: a gesture on the default frame is something to cancel, so Esc in full screen cancels it +/// (as in Standard Screen Mode) and full screen stays. +#[test] +fn esc_in_full_screen_cancels_a_crop_drag_on_the_default_frame() { + use crate::canvas::{ToolEvent, tool_event}; + let none = egui::Modifiers::NONE; + // Inside draws a new box, on the corner handle resizes, outside turns. + for (what, from) in [("draw", [20.0, 20.0]), ("resize", [100.0, 80.0]), ("turn", [130.0, 110.0])] { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 50.0, pressure: 1.0 }, none); + assert!(app.crop.drag.is_some() && app.crop.default_frame, "{what}: mid-gesture on the default frame"); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen", "{what}: Esc cancels the gesture, full screen stays"); + assert!(app.crop.drag.is_none(), "{what}: the gesture is cancelled"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 50.0 }, none); + assert!(!crate::crop_ui::pending(&app), "{what}: the release leaves no pending crop"); + } +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); diff --git a/crates/ui-egui/src/move_history_names_tests.rs b/crates/ui-egui/src/move_history_names_tests.rs new file mode 100644 index 0000000000..520414bef3 --- /dev/null +++ b/crates/ui-egui/src/move_history_names_tests.rs @@ -0,0 +1,173 @@ +//! #11383: the Move tool's History steps have the reference application's names: a drag and an +//! Alt+drag copy of a layer are "Move", arrow-key nudges are "Nudge" (one per press, or one per +//! run with Bundle Arrow-Key Nudges on), an Alt+drag of selected pixels is "Duplicate". An +//! Alt+arrow nudge keeps "Duplicate + Move" (not measured). + +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A 64x64 document with one painted layer (8..24)^2, selected, and the Move tool active. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = false; + // Snapping (to the document centre with a 1:1 test view) would shift the expected offsets. + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app +} + +fn bounds(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> Rect { + app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + let mid = [from[0] + by[0] / 2.0, from[1] + by[1] / 2.0]; + crate::canvas::tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, mods); + let end = [from[0] + by[0], from[1] + by[1]]; + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, mods); + crate::canvas::tool_event(app, ToolEvent::Up { x: end[0], y: end[1] }, mods); +} + +fn press(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers, time: f64) { + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.time = time; + i.modifiers = mods; + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + }); + assert!(crate::move_mods::arrow_keys(app, &ctx)); +} + +fn history(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn newest(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().pop() +} + +/// The five presses of the reference measurement: ->, Shift+->, down, Shift+down, <-. +fn five_presses(app: &mut PhotocraftApp) { + let keys = [ + (egui::Key::ArrowRight, egui::Modifiers::NONE), + (egui::Key::ArrowRight, egui::Modifiers::SHIFT), + (egui::Key::ArrowDown, egui::Modifiers::NONE), + (egui::Key::ArrowDown, egui::Modifiers::SHIFT), + (egui::Key::ArrowLeft, egui::Modifiers::NONE), + ]; + for (i, (key, mods)) in keys.into_iter().enumerate() { + press(app, key, mods, 1.0 + 0.1 * i as f64); + } +} + +#[test] +fn a_drag_of_a_layer_is_one_move_step() { + let mut app = app(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [10.0, 4.0], egui::Modifiers::NONE); + assert_eq!(bounds(&app, id), Rect::new(18, 12, 34, 28)); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Move")); +} + +#[test] +fn an_alt_drag_copy_of_a_layer_is_one_move_step() { + let mut app = app(); + let orig = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [20.0, 10.0], egui::Modifiers::ALT); + let copy = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(copy, orig, "the copy is selected"); + assert_eq!(bounds(&app, copy), Rect::new(28, 18, 44, 34), "at the offset"); + assert_eq!(bounds(&app, orig), Rect::new(8, 8, 24, 24), "the original stays"); + assert_eq!(history(&app), h0 + 1, "the copy and the move are one step"); + assert_eq!(newest(&app).as_deref(), Some("Move")); +} + +#[test] +fn five_arrow_presses_with_bundling_off_are_five_nudge_steps() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + five_presses(&mut app); + assert_eq!(bounds(&app, id), Rect::new(8 + 1 + 10 - 1, 8 + 1 + 10, 24 + 10, 24 + 11)); + assert_eq!(history(&app), h0 + 5); + let entries = app.session.active().unwrap().history.entries(); + assert_eq!(&entries[entries.len() - 5..], ["Nudge"; 5]); +} + +#[test] +fn five_arrow_presses_with_bundling_on_are_one_nudge_step() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = true); + let h0 = history(&app); + five_presses(&mut app); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Nudge")); +} + +#[test] +fn an_alt_drag_of_selected_pixels_is_one_duplicate_step() { + let mut app = app(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [20.0, 10.0], egui::Modifiers::ALT); + // The copied piece floats until something drops it. + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(history(&app), h0 + 1, "the copy and its move are one step"); + assert_eq!(newest(&app).as_deref(), Some("Duplicate")); +} + +#[test] +fn a_blank_or_overlong_step_name_drops_as_move() { + let mut app = app(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + for name in [" ", &"x".repeat(65)] { + app.session.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "stepName": name})).unwrap(); + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(newest(&app).as_deref(), Some("Move"), "{name:?}"); + } + app.session.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "stepName": "Duplicate"})).unwrap(); + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(newest(&app).as_deref(), Some("Duplicate")); +} + +#[test] +fn an_alt_arrow_nudge_keeps_duplicate_plus_move() { + let mut app = app(); + let h0 = history(&app); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT, 1.0); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Duplicate + Move")); +} + +/// A zero offset moves nothing: no step is added, and the step on top (here the paint) keeps its +/// name. +#[test] +fn a_zero_nudge_renames_no_earlier_step() { + let mut app = app(); + for bundle in [false, true] { + app.session.edit_prefs(|p| p.tools.bundle_nudges = bundle); + let before: Vec = app.session.active().unwrap().history.entries().to_vec(); + crate::move_mods::nudge(&mut app, 0.0, 0.0, false); + let after: Vec = app.session.active().unwrap().history.entries().to_vec(); + assert_eq!(after, before, "bundle {bundle}"); + } +} diff --git a/crates/ui-egui/src/move_shift_diagonal_tests.rs b/crates/ui-egui/src/move_shift_diagonal_tests.rs new file mode 100644 index 0000000000..0929069ede --- /dev/null +++ b/crates/ui-egui/src/move_shift_diagonal_tests.rs @@ -0,0 +1,112 @@ +//! Shift+drag of a layer with the Move tool locks to the nearest of the eight directions (#11382). +//! +//! Measured in the reference application (layer Red, 300x200 px, Auto-Select on, target Layer): +//! Shift+drag +100,+30 moves +100,0 and Shift+drag +80,+80 moves +80,+80, each as one "Move" +//! step. With Alt as well, the copy lands at the same offset. + +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A 400x300 document with one painted layer covering (20, 20, 120, 80), selected. The drag +/// starts inside it, so Auto-Select picks the same layer. +fn app() -> (PhotocraftApp, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 20, 120, 80), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.tool = Tool::Move; + // Snapping would shift the expected offsets. + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + (app, id) +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + let mid = [from[0] + by[0] / 2.0, from[1] + by[1] / 2.0]; + crate::canvas::tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, mods); + let end = [from[0] + by[0], from[1] + by[1]]; + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, mods); + crate::canvas::tool_event(app, ToolEvent::Up { x: end[0], y: end[1] }, mods); +} + +const START: Rect = Rect::new(20, 20, 120, 80); +const FROM: [f64; 2] = [60.0, 50.0]; + +fn shifted(dx: i32, dy: i32) -> Rect { + Rect::new(START.x0 + dx, START.y0 + dy, START.x1 + dx, START.y1 + dy) +} + +/// Runs a plain Shift drag and checks the layer moved by `expect`. +fn shift_drag(by: [f64; 2], expect: [i32; 2]) { + let (mut app, id) = app(); + assert_eq!(bounds(&app, id), START); + drag(&mut app, FROM, by, egui::Modifiers::SHIFT); + assert_eq!(bounds(&app, id), shifted(expect[0], expect[1])); + assert_eq!(app.session.active().unwrap().active_layer, Some(id)); +} + +/// Runs an Alt+Shift drag and checks the copy landed at `expect` and is selected. +fn alt_shift_drag(by: [f64; 2], expect: [i32; 2]) { + let (mut app, id) = app(); + assert_eq!(bounds(&app, id), START); + let n0 = app.session.active().unwrap().doc.layers.len(); + drag(&mut app, FROM, by, egui::Modifiers::SHIFT | egui::Modifiers::ALT); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), n0 + 1, "one copy"); + let copy = st.active_layer.unwrap(); + assert_ne!(copy, id, "the copy is selected"); + assert_eq!(bounds(&app, copy), shifted(expect[0], expect[1])); + assert_eq!(bounds(&app, id), START, "the original stays"); +} + +#[test] +fn shift_drag_mostly_horizontal_locks_to_the_axis() { + // Reference: Shift+drag +100,+30 moves +100,0. + shift_drag([100.0, 30.0], [100, 0]); +} + +#[test] +fn shift_drag_on_the_diagonal_moves_along_it() { + // Reference: Shift+drag +80,+80 moves +80,+80 (it was 0,+80 before). + shift_drag([80.0, 80.0], [80, 80]); +} + +#[test] +fn shift_drag_near_the_axis_stays_on_the_axis() { + // Same rule as the +100,+30 case: a small offset off the axis does not leave it. + shift_drag([100.0, 5.0], [100, 0]); +} + +#[test] +fn alt_shift_drag_mostly_horizontal_copies_along_the_axis() { + // Reference rule for +100,+30, applied to the duplicate and move. + alt_shift_drag([100.0, 30.0], [100, 0]); +} + +#[test] +fn alt_shift_drag_on_the_diagonal_copies_along_it() { + // Reference: the copy lands at +80,+80. + alt_shift_drag([80.0, 80.0], [80, 80]); +} + +#[test] +fn alt_shift_drag_near_the_axis_copies_along_the_axis() { + alt_shift_drag([100.0, 5.0], [100, 0]); +} diff --git a/crates/ui-egui/src/nudge_bundle_tests.rs b/crates/ui-egui/src/nudge_bundle_tests.rs index d18bbddafa..1e1f0542ba 100644 --- a/crates/ui-egui/src/nudge_bundle_tests.rs +++ b/crates/ui-egui/src/nudge_bundle_tests.rs @@ -48,8 +48,8 @@ fn tapping_an_arrow_key_ten_times_is_one_history_step() { assert_eq!(bounds(&h).x0, start.x0 + 10); assert_eq!(steps(&h), n0 + 1, "ten taps, one state"); let entries = h.state().session.active().unwrap().history.entries(); - assert_eq!(entries.last().map(String::as_str), Some("Move")); - assert_eq!(entries.iter().filter(|e| *e == "Move").count(), 1); + assert_eq!(entries.last().map(String::as_str), Some("Nudge")); + assert_eq!(entries.iter().filter(|e| *e == "Nudge").count(), 1); h.state_mut().session.undo(); assert_eq!(bounds(&h), start, "one Undo takes all ten pixels back"); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 222b1829f1..9fd21b2f20 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -44,6 +44,7 @@ pub mod layer_row_ui; pub(super) mod layer_transfer; pub mod layer_tree_ui; mod layers; +mod layers_empty_menu; pub mod layers_panel_ui; pub(super) mod mask_props_ui; pub mod mask_thumbs_ui; @@ -60,7 +61,7 @@ pub mod swatches_ui; pub(super) mod timeline_ui; pub(crate) mod title_bar; pub(super) mod titlebar; -mod toolbar; +pub(crate) mod toolbar; pub mod type_panels_ui; pub(super) mod variables_ui; @@ -262,6 +263,13 @@ mod title_tests { } } +/// Whether blend mode `m` starts one of the groups Photoshop's blend mode lists draw a line above: +/// Normal, Dissolve (and Pass Through, Clear) | darken | lighten | contrast | difference | colour +/// (#5532). The first mode of a list never gets a line. +pub(crate) fn starts_blend_group(m: BlendMode) -> bool { + matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) +} + fn label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(s).color(t.text_dim)); @@ -822,7 +830,7 @@ mod snapshot_row_tests { let p = trash(&h); click(&mut h, p); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 1, "the trash deleted the snapshot (the first snapshot stays)"); + assert_eq!(st.snapshots.len(), 1, "the trash deleted the snapshot, leaving the first one"); assert_eq!(st.history.entries().len(), steps, "and left the states alone"); // An edit after the click deselects the snapshot: the trash deletes the state. @@ -836,7 +844,7 @@ mod snapshot_row_tests { let p = trash(&h); click(&mut h, p); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 2, "the snapshot is kept"); + assert_eq!(st.snapshots.len(), 2, "the snapshot is kept, after the first one"); assert_eq!(st.history.entries().len(), steps - 1, "the current state is deleted"); } @@ -849,7 +857,7 @@ mod snapshot_row_tests { let to = trash(&h); drag(&mut h, egui::pos2(row.x, row.y), to); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 1, "the dragged snapshot is deleted (the first snapshot stays)"); + assert_eq!(st.snapshots.len(), 1, "the dragged snapshot is deleted, leaving the first one"); assert_eq!(st.history.entries().len(), steps, "no state is"); // The second-to-last state: it and the state after it go, as with Delete. @@ -862,7 +870,7 @@ mod snapshot_row_tests { drag(&mut h, egui::pos2(row.x, row.y), to); let st = h.state().session.active().unwrap(); assert_eq!(st.history.entries().len(), 1, "the dragged state and the ones after it are deleted"); - assert_eq!(st.snapshots.len(), 2, "the snapshot is kept"); + assert_eq!(st.snapshots.len(), 2, "the snapshot is kept, after the first one"); } /// With Allow Non-Linear History and only the oldest state current, a click on the trash has @@ -1001,6 +1009,73 @@ mod snapshot_row_tests { assert!(h.state().ui.dialogs.is_empty()); } + /// History Options › Show New Snapshot Dialog by Default (#2966): a plain click on the camera + /// opens New Snapshot as ⌥-click does, and ⌥-click still opens it (Photoshop's button always + /// prompts with the option on). Off, a plain click takes the snapshot at once. + #[test] + fn show_new_snapshot_dialog_by_default_makes_a_click_open_the_dialog() { + for (dialog_by_default, modifiers) in [(true, egui::Modifiers::NONE), (true, egui::Modifiers::ALT), (false, egui::Modifiers::NONE)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("history.options", json!({"showNewSnapshotDialogByDefault": dialog_by_default})).unwrap(); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + history(app, ui); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(4); + let mut buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.sort_by(|a, b| b.center().x.total_cmp(&a.center().x)); + let camera = buttons[1].center(); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(camera)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: camera, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); + let snaps = h.state().session.active().unwrap().snapshots.len(); + let dialog = h.state().ui.dialogs.last().is_some_and(|d| crate::snapshot_dialog::owns(&d.fields)); + if dialog_by_default { + assert!(dialog, "{modifiers:?}: the dialog opens"); + assert_eq!(snaps, 1, "{modifiers:?}: no snapshot until OK"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().snapshots.last().unwrap().name, "Snapshot 1"); + } else { + assert!(!dialog, "off: no dialog"); + assert_eq!(snaps, 2, "off: the click takes the snapshot"); + } + } + } + + /// History Options › Automatically Create First Snapshot: on, the panel's top row is a + /// snapshot named after the document; off, there is none above the opening state. + #[test] + fn the_first_snapshot_row_follows_the_option() { + for first in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("history.options", json!({"automaticallyCreateFirstSnapshot": first})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let name = app.session.active().unwrap().doc.name.clone(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + assert_eq!(h.query_by_label(&name).is_some(), first, "a snapshot row named after the document: {first}"); + } + } + /// Snapshot thumbnails belong to the snapshot's document (#6087): closing a document drops /// them, and a document reopened with the same id (a `.pcraft` keeps it) gets fresh ones /// rather than the closed document's, even before the views are synced. diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d618999d9f..ee83682dcd 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,11 +1,13 @@ -use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; +use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, starts_blend_group, vec2, widgets}; /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the -/// mode. Returns the mode picked this frame. +/// mode. Returns the mode picked this frame. A list of every blend mode draws the Layers panel's +/// group lines (#5532; Clear starts none); a short one (the Healing Brush's) draws none. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { let opts: Vec<(Option, &str)> = modes.iter().map(|m| (*m, m.map_or("Clear", BlendMode::label))).collect(); + let grouped = BlendMode::LAYER_MODES.iter().all(|m| modes.contains(&Some(*m))); let mut mode = current; - let chosen = widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0; + let chosen = widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| grouped && m.is_some_and(starts_blend_group)).0; chosen.last().copied() } @@ -21,7 +23,7 @@ pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraf } else { (Some(b.mode), false) }; - if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() { + if !widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| m.0.is_some_and(starts_blend_group)).0.is_empty() { *clear = mode == (None, false); b.behind = mode.1; if mode.1 { diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 3baa1b99bf..fd6ad08178 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -90,13 +90,15 @@ struct RowInteractions<'a> { masked: Option<&'a photocraft_doc::Layer>, actions: &'a mut Vec<(String, Value)>, mask_click: &'a mut Option, + /// Whether Merge Channels… can run (enough grayscale documents of one size). + merge_enabled: bool, } fn handle_row_interactions(ui: &mut egui::Ui, mut row: RowInteractions<'_>) { let rename_id = egui::Id::unique(("chan-rename", row.doc.id.0, format!("{:?}", row.row.reference()))); handle_channel_row_click(ui, &mut row); handle_channel_row_double_click(ui, &mut row, rename_id); - row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.actions)); + row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.merge_enabled, row.actions)); } fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { @@ -167,7 +169,14 @@ fn handle_alpha_double_click(ui: &mut egui::Ui, row: &mut RowInteractions<'_>, i } } -fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, masked: Option<&photocraft_doc::Layer>, actions: &mut Vec<(String, Value)>) { +fn draw_row_menu( + ui: &mut egui::Ui, + row: Row, + doc: &photocraft_doc::Document, + masked: Option<&photocraft_doc::Layer>, + merge_enabled: bool, + actions: &mut Vec<(String, Value)>, +) { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(210.0); match row { @@ -177,11 +186,11 @@ fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, ma Row::LayerMask => draw_mask_menu(ui, masked, actions), } ui.separator(); - item(ui, actions, "New Channel…", "channel.new", json!({})); + item(ui, actions, "New Channel…", "ui.newChannel", json!({})); item(ui, actions, "New Spot Channel…", "ui.newSpotChannel", json!({})); ui.separator(); item(ui, actions, "Split Channels", "channel.split", json!({})); - item(ui, actions, "Merge Channels…", "channel.merge", json!({})); + item_enabled(ui, actions, "Merge Channels…", "ui.mergeChannels", json!({}), merge_enabled); }); } @@ -288,14 +297,14 @@ fn row_presentation(row: Row, context: &RowPresentationContext<'_>, mask_visible !context.view.quick_mask_hidden, context.view.target == ChannelTarget::Composite, ), - Row::LayerMask => { - (context.masked.map_or_else(String::new, |layer| format!("{} Mask", layer.name)), usize::MAX, None, mask_visible, context.mask_targeted) - } + Row::LayerMask => (context.masked.map_or_else(String::new, mask_row_name), usize::MAX, None, mask_visible, context.mask_targeted), }; RowPresentation { name, thumb_idx, slot, visible, selected } } pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // Greyed in the rows' menu as in the panel menu and the command palette. + let merge_enabled = crate::menus::is_enabled(app, "channel.merge"); let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { ui.add_space(6.0); @@ -316,10 +325,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The active layer's mask (Photoshop lists it, in italics, below the colour channels). let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()); let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?))); - let mask_targeted = masked.is_some() - && (app.ui.mask_target || view.target == ChannelTarget::LayerMask) - && (view.target == ChannelTarget::Composite || view.target == ChannelTarget::LayerMask) - && !quick; + let mask_targeted = mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick); // Multichannel images are their ink channels only (no composite or colour rows). let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel; let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] }; @@ -436,6 +442,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { masked, actions: &mut actions, mask_click: &mut mask_click, + merge_enabled, }, ); } @@ -467,13 +474,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }; // Photoshop's order from the left: Load as selection, Save selection, New, Delete. widgets::panel_footer(ui, |ui| { - let target_ref = match view.target { - ChannelTarget::Alpha(i) => json!(i), - ChannelTarget::Color(k) => json!({ "color": k }), - ChannelTarget::Composite => json!("composite"), - ChannelTarget::LayerMask => json!("mask"), - ChannelTarget::VectorMask => json!("vectorMask"), - }; + let target_ref = target_reference(view.target); let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")); if trash.clicked() { actions.push(("channel.delete".into(), json!({}))); @@ -510,6 +511,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } + // The row menu's New Channel… and Merge Channels… ask first, as the panel menu's do + // (the footer's New button makes a channel at once). + let asks = match id.as_str() { + "ui.newChannel" => Some("channel.new"), + "ui.mergeChannels" => Some("channel.merge"), + _ => None, + }; + if let Some(cmd) = asks { + if let Err(e) = crate::menus::invoke(app, &ctx, cmd, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + continue; + } if id == "ui.newSpotChannel" { crate::channel_options_ui::open_new_spot(app); continue; @@ -526,6 +541,91 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The engine reference of the targeted channel (`channel.*` and `select.loadSelection` take it). +fn target_reference(target: ChannelTarget) -> Value { + match target { + ChannelTarget::Alpha(i) => json!(i), + ChannelTarget::Color(k) => json!({ "color": k }), + ChannelTarget::Composite => json!("composite"), + ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::VectorMask => json!("vectorMask"), + } +} + +/// Whether the selected layer's mask row is the one shown targeted: the layer has a mask, and it +/// (not the composite) is what painting edits, outside Quick Mask. +fn mask_row_targeted(has_mask: bool, mask_target: bool, target: ChannelTarget, quick: bool) -> bool { + has_mask && (mask_target || target == ChannelTarget::LayerMask) && matches!(target, ChannelTarget::Composite | ChannelTarget::LayerMask) && !quick +} + +/// The layer mask row's name, " Mask", as Photoshop lists it. +fn mask_row_name(layer: &photocraft_doc::Layer) -> String { + format!("{} Mask", layer.name) +} + +/// The Channels panel's own items at the top of its panel menu (☰, #11451), in Photoshop's +/// order: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, +/// Channel Options…, Split Channels, Merge Channels…. The ones that act on one channel take the +/// targeted row and are greyed without one (a colour composite, several colour channels). New +/// Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs, as from +/// the command palette; Split Channels runs at once. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + use crate::layers_panel_ui::run_menu_command; + let target = MenuTarget::of(app); + run_menu_command(app, ui, tl!("New Channel…"), "channel.new", Some(json!({}))); + run_menu_command(app, ui, tl!("Duplicate Channel"), "channel.duplicate", target.duplicate); + run_menu_command(app, ui, tl!("Delete Channel"), "channel.delete", target.alpha.map(|i| json!({ "channel": i }))); + ui.separator(); + run_menu_command(app, ui, tl!("New Spot Channel…"), "channel.newSpot", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Spot Channel"), "channel.mergeSpot", target.spot.map(|i| json!({ "channel": i }))); + run_menu_command(app, ui, tl!("Channel Options…"), "channel.options", target.options.then(|| json!({}))); + ui.separator(); + run_menu_command(app, ui, tl!("Split Channels"), "channel.split", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Channels…"), "channel.merge", Some(json!({}))); +} + +/// What the panel menu's single-channel items act on: the targeted row. +#[derive(Default)] +struct MenuTarget { + /// The `channel.duplicate` params: the targeted row as its footer's New button copies it. + duplicate: Option, + /// The targeted alpha or spot channel. + alpha: Option, + /// The targeted spot channel. + spot: Option, + /// Whether Channel Options… has something to show (an alpha / spot channel, Quick Mask or a + /// layer mask), as the menu bar's command decides. + options: bool, +} + +impl MenuTarget { + fn of(app: &PhotocraftApp) -> Self { + let mask = app.layer_mask_targeted(); + let Some(st) = app.session.active() else { return Self::default() }; + let view = &st.channel_view; + let alpha = match view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(i), + _ => None, + }; + let quick = st.doc.quick_mask.is_some(); + let masked = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| l.mask.is_some()); + let duplicate = match view.target { + // The composite targeted in Quick Mask mode: the Quick Mask row is the selected one. + ChannelTarget::Composite if quick => Some(json!({ "channel": "quickMask" })), + _ if mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick) => { + masked.and_then(|layer| drop_action(Row::LayerMask, &mask_row_name(layer), Some(layer.id.0), Footer::New)).map(|(_, p)| p) + } + ChannelTarget::Alpha(_) if alpha.is_some() => Some(json!({ "channel": target_reference(view.target) })), + ChannelTarget::Color(_) if view.selected_colors.len() <= 1 => Some(json!({ "channel": target_reference(view.target) })), + // A one-channel image (Grayscale, Bitmap, Duotone): the composite is its channel. + ChannelTarget::Composite if channel_cmds::color_count(&st.doc) == 1 => Some(json!({ "channel": "composite" })), + _ => None, + }; + let spot = alpha.filter(|&i| st.doc.channels.get(i).is_some_and(|c| c.spot.is_some())); + Self { duplicate, alpha, spot, options: alpha.is_some() || quick || mask } + } +} + /// The footer buttons a row can be dropped on. #[derive(Clone, Copy)] enum Footer { @@ -575,7 +675,12 @@ pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) { } fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) { - if ui.button(tl!(&label)).clicked() { + item_enabled(ui, actions, label, cmd, p, true); +} + +/// [`item`], greyed unless `enabled`. +fn item_enabled(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value, enabled: bool) { + if ui.add_enabled(enabled, egui::Button::new(tl!(&label))).clicked() { actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 3d57762d03..123efd3ade 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -440,3 +440,212 @@ fn recording_a_colour_row_click_saves_as_photoshop_select() { assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); } } + +/// The panel menu's single-channel items act on the targeted channel (#11451): a targeted spot +/// channel can be duplicated, given options, merged or deleted; a colour channel duplicated. +#[test] +fn panel_menu_items_act_on_the_targeted_channel() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "fromSelection": false})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(!h.get_by_label(label).accesskit_node().is_disabled(), "{label} acts on the targeted spot channel"); + } + h.get_by_label("Channel Options…").click(); + h.run_steps(2); + assert!(h.state().ui.dialogs.iter().any(|d| crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!(0))); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink", "Ink copy"]); + h.get_by_label("Delete Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy"], "the targeted channel is deleted"); + h.state_mut().run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + h.run_steps(2); + assert!(h.get_by_label("Delete Channel").accesskit_node().is_disabled(), "colour channels aren't deleted here"); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy", "Green copy"]); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +fn alpha_names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() +} + +fn menu_app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24, "mode": mode})).unwrap(); + app +} + +fn menu_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h +} + +fn menu_disabled(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + h.get_by_label(label).accesskit_node().is_disabled() +} + +fn menu_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, label: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_label(label).click(); + h.run_steps(2); + assert!(!h.state().ui.status_error, "{label}: {}", h.state().ui.status); +} + +/// A grayscale image's composite is its one channel: Duplicate Channel copies it, as the row +/// menu does; an RGB composite stays greyed. +#[test] +fn panel_menu_duplicates_the_gray_composite() { + assert!(menu_disabled(&menu_harness(menu_app("rgb")), "Duplicate Channel"), "nothing to duplicate on the RGB composite"); + let mut h = menu_harness(menu_app("grayscale")); + assert!(!menu_disabled(&h, "Duplicate Channel")); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Gray copy"]); +} + +/// Duplicate Channel copies a targeted Quick Mask or layer mask, as dropping the row on New does +/// (the mask's copy is " Mask copy"). +#[test] +fn panel_menu_duplicates_quick_mask_and_layer_mask() { + let mut app = menu_app("rgb"); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the Quick Mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(h.state().session.active().unwrap().doc.channels.len(), 1); + + let mut app = menu_app("rgb"); + app.run("layer.new.layer", json!({"name": "Masked"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the layer mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Masked Mask copy"]); +} + +/// In Quick Mask mode, Channel Options… on a targeted alpha channel opens that channel's options, +/// from the panel menu and the command palette (`menus::invoke`) alike; with the composite +/// targeted it is Quick Mask's. +#[test] +fn channel_options_in_quick_mask_take_the_targeted_alpha() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("quickMask"), "the composite targeted: Quick Mask Options"); + app.ui.dialogs.clear(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!(0), "the dispatcher opens the alpha's options"); + app.ui.dialogs.clear(); + let mut h = menu_harness(app); + menu_click(&mut h, "Channel Options…"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["channel"], json!(0), "so does the panel menu"); +} + +/// Duplicate Channel waits for one channel: not several colour channels at once. Without a +/// document every item is greyed. +#[test] +fn panel_menu_greys_items_without_one_channel_or_a_document() { + let mut app = menu_app("rgb"); + app.run("channel.target", json!({"channel": {"color": 0}})).unwrap(); + app.run("channel.target", json!({"channel": {"color": 1}, "extend": true})).unwrap(); + assert_eq!(app.session.active().unwrap().channel_view.selected_colors.len(), 2); + assert!(menu_disabled(&menu_harness(app), "Duplicate Channel"), "two colour channels are targeted"); + let h = menu_harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for label in [ + "New Channel…", + "Duplicate Channel", + "Delete Channel", + "New Spot Channel…", + "Merge Spot Channel", + "Channel Options…", + "Split Channels", + "Merge Channels…", + ] { + assert!(menu_disabled(&h, label), "{label} waits for a document"); + } +} + +/// Merge Spot Channel prints the targeted spot channel into the image and removes it: a solid +/// blue ink over the left half turns those pixels blue and leaves the rest white. +#[test] +fn panel_menu_merges_the_targeted_spot_channel() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "color": "#0000ff", "solidity": 100})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("channel.target", json!({"channel": 1})).unwrap(); + let pixel = |app: &PhotocraftApp, x: usize, y: usize| { + let doc = &app.session.active().unwrap().doc; + let comp = photocraft_compose::render(doc, doc.bounds()); + let p = comp.px[y * doc.size.width as usize + x]; + [p[0], p[1], p[2]].map(|v| (v * 255.0).round() as u8) + }; + assert_eq!((pixel(&app, 4, 4), pixel(&app, 24, 4)), ([255, 255, 255], [255, 255, 255]), "the ink is not in the image yet"); + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Spot Channel"); + assert_eq!(alpha_names(h.state()), ["Alpha 1"], "the spot channel is merged, the alpha kept"); + assert_eq!(pixel(h.state(), 4, 4), [0, 0, 255], "the ink is printed where the channel had it"); + assert_eq!(pixel(h.state(), 24, 4), [255, 255, 255], "and nowhere else"); +} + +/// Merge Channels… asks first, as in Photoshop: the dialog picks the mode and the document for +/// each channel; nothing merges until OK. With params the command runs directly. +#[test] +fn panel_menu_merge_channels_opens_its_dialog() { + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Channels…"); + assert_eq!(h.state().session.documents().len(), 3, "nothing is merged before OK"); + let d = h.state().ui.dialogs.last().expect("the Merge Channels dialog"); + assert_eq!(d.fields["__label"], json!("Merge Channels…")); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("mode".into(), json!("lab")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 1, "the sources close"); + assert_eq!(docs[0].doc.mode, photocraft_doc::ColorMode::Lab, "in the mode picked"); + + let ctx = egui::Context::default(); + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let r = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap(); + assert!(r["dialog"].is_u64() && app.session.documents().len() == 3, "the dispatcher asks too"); + app.ui.dialogs.clear(); + crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({"mode": "rgb"})).unwrap(); + assert!(app.ui.dialogs.is_empty() && app.session.documents().len() == 1, "explicit params merge directly"); +} + +/// Two grayscale documents fill no mode: Merge Channels… is greyed in the panel menu, and the +/// dispatcher says why instead of opening the dialog; a third one enables it. +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut app = menu_app("grayscale"); + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + let ctx = egui::Context::default(); + let why = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap_err(); + assert!(why.contains("three") && app.ui.dialogs.is_empty(), "{why}"); + let mut h = menu_harness(app); + assert!(menu_disabled(&h, "Merge Channels…"), "two documents are not enough"); + h.state_mut().run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + h.run_steps(2); + assert!(!menu_disabled(&h, "Merge Channels…"), "three are"); +} diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index aad55b96d2..38d3797014 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -144,9 +144,11 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } let can_snap = app.session.is_enabled("history.newSnapshot"); - // ⌥-click opens New Snapshot (name, From) instead, as in Photoshop. + // ⌥-click opens New Snapshot (name, From) instead, as in Photoshop; with History + // Options › Show New Snapshot Dialog by Default a plain click does too. + let dialog_by_default = photocraft_engine::history_options_cmds::show_new_snapshot_dialog(&app.session); if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { - if ui.input(|i| i.modifiers.alt) { + if dialog_by_default || ui.input(|i| i.modifiers.alt) { snapshot_dialog = true; } else { footer_cmd = Some(("history.newSnapshot", json!({}))); diff --git a/crates/ui-egui/src/panels/history_menu_ui.rs b/crates/ui-egui/src/panels/history_menu_ui.rs index 245fa986d8..fa865d42c4 100644 --- a/crates/ui-egui/src/panels/history_menu_ui.rs +++ b/crates/ui-egui/src/panels/history_menu_ui.rs @@ -42,8 +42,9 @@ fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { if cmd == "window.panel.close" { return !app.session.prefs().workspace_locked; } - // An open Free Transform owns the history (see `panels::history`). - app.ui.transform.is_none() && app.session.is_enabled(cmd) + // An open Free Transform owns the history (see `panels::history`), and a drawn crop frame + // holds its jumps back (`crop_ui::holds_history`). + app.ui.transform.is_none() && !crate::crop_ui::holds_history(app, cmd) && app.session.is_enabled(cmd) } /// Draw the History items of the panel menu and run the one clicked. @@ -158,6 +159,26 @@ mod tests { } } + /// A drawn crop frame greys the items that move the document under it (#9285). + #[test] + fn jumps_wait_for_a_drawn_crop_frame() { + let mut app = app(); + app.ui.tool = crate::state::Tool::Crop; + app.ui.crop_rect = Some([4.0, 4.0, 40.0, 30.0]); + app.crop.default_frame = false; + app.session.execute("history.stepBackward", json!({})).unwrap(); + let mut h = harness(app); + for label in ["Step Backward", "Step Forward", "Delete"] { + assert!(is_disabled(&h, label), "{label}"); + } + assert!(!is_disabled(&h, "New Snapshot…"), "a snapshot leaves the document as it is"); + h.state_mut().crop.default_frame = true; + h.run_steps(2); + assert!(!is_disabled(&h, "Step Backward"), "the tool's own frame around the canvas holds nothing back"); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (2, 2)); + } + #[test] fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { let mut h = harness(app()); diff --git a/crates/ui-egui/src/panels/layer_row_ui.rs b/crates/ui-egui/src/panels/layer_row_ui.rs index 82d924bacf..18258c46ea 100644 --- a/crates/ui-egui/src/panels/layer_row_ui.rs +++ b/crates/ui-egui/src/panels/layer_row_ui.rs @@ -277,21 +277,25 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } } ui.separator(); - run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage", Some(json!({}))); ui.separator(); - if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { + let mut lock_layers = egui::Button::new(tl!("Lock Layers…")); + if let Some(key) = crate::shortcuts::shortcut_label(app, "layer.lockLayers") { + lock_layers = lock_layers.shortcut_text(key); + } + if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), lock_layers).clicked() { app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); ui.close(); } - run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer", Some(json!({}))); let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); if any_hidden { - run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers", Some(json!({}))); } else { - run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers", Some(json!({}))); } ui.separator(); - run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard", Some(json!({}))); ui.separator(); ui.menu_button(tl!("Filter Options"), |ui| { let mut show = app.ui.layers_panel_options.show_filters; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 835b370af2..856f5a09ff 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -4,7 +4,9 @@ use super::layer_drag::{ }; use super::layer_row::{RowSel, layer_row}; use super::options_bar::{LAYER_PCT_W, body_text_width, pct_action, scrub_pct_label}; -use super::{BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, theme, vec2, widgets}; +use super::{ + BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, starts_blend_group, theme, vec2, widgets, +}; // ----------------------------------------------------------------------------- layers @@ -58,7 +60,7 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let mut m = BlendMode::Normal; let options = blend_options(false, m); - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width, |m| starts_blend_group(*m)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = 100.0; widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -154,7 +156,9 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let (chosen, hovered) = - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width, |m| { + starts_blend_group(*m) + }); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { let mut p = targets.clone(); @@ -314,8 +318,21 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A click on that empty space deselects every layer, as in Photoshop (#8100). It // starts under the last layer's sub-rows, which take their own clicks. let empty = Rect::from_min_max(pos2(below.left(), rows_bottom), below.max); - if empty.height() > 0.0 && ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { - actions.push(("select.deselectLayers".into(), json!({}))); + if empty.height() > 0.0 { + let response = ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()); + if response.clicked() && !selection.is_empty() { + actions.push(("select.deselectLayers".into(), json!({}))); + } + // A right-click there opens the thumbnail size and clipping choices (#11404). + response.context_menu(|ui| super::layers_empty_menu::show(app, ui)); + } + } else { + // No row drawn (a filter that matches no layer): the whole list is empty space, + // and its right-click opens the same menu. + let empty = Rect::from_min_max(pos2(ui.max_rect().left(), rows_bottom), pos2(ui.max_rect().right(), ui.clip_rect().bottom())); + if empty.height() > 0.0 && empty.width() > 0.0 { + let response = ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()); + response.context_menu(|ui| super::layers_empty_menu::show(app, ui)); } } // Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The diff --git a/crates/ui-egui/src/panels/layers_empty_menu.rs b/crates/ui-egui/src/panels/layers_empty_menu.rs new file mode 100644 index 0000000000..17c1c0d6e0 --- /dev/null +++ b/crates/ui-egui/src/panels/layers_empty_menu.rs @@ -0,0 +1,43 @@ +//! The menu a right-click on the empty area below the last layer row opens (#11404): the +//! thumbnail size and what a thumbnail frames. Both are the settings Panel Options... edits. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::LayerThumbnailSize; + +/// The four sizes, in menu order. +pub(super) fn sizes() -> [(LayerThumbnailSize, &'static str); 4] { + [ + (LayerThumbnailSize::None, tl!("No Thumbnails")), + (LayerThumbnailSize::Small, tl!("Small Thumbnails")), + (LayerThumbnailSize::Medium, tl!("Medium Thumbnails")), + (LayerThumbnailSize::Large, tl!("Large Thumbnails")), + ] +} + +pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let current = app.ui.layers_panel_options.thumbnail_size; + for (size, label) in sizes() { + let mut on = current == size; + if crate::widgets::checkbox(ui, &mut on, label).clicked() { + app.ui.layers_panel_options.thumbnail_size = size; + super::layers_panel_ui::persist_options(app); + ui.close(); + } + } + ui.separator(); + let clip = app.session.prefs().interface.clip_thumbnails_to_layer_bounds; + for (to_layer, label) in [(true, tl!("Clip Thumbnails to Layer Bounds")), (false, tl!("Clip Thumbnails to Document Bounds"))] { + let mut on = clip == to_layer; + if crate::widgets::checkbox(ui, &mut on, label).clicked() { + if clip != to_layer + && let Err(e) = app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": to_layer})) + { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } + } +} diff --git a/crates/ui-egui/src/panels/layers_panel_ui.rs b/crates/ui-egui/src/panels/layers_panel_ui.rs index d640e3850d..5bf63ac814 100644 --- a/crates/ui-egui/src/panels/layers_panel_ui.rs +++ b/crates/ui-egui/src/panels/layers_panel_ui.rs @@ -27,11 +27,17 @@ pub fn restore_options(app: &mut PhotocraftApp) { } } -/// The real menu command dispatcher handles rename's in-place editor and other shell routing. -pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { - if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { +/// A panel-menu item running menu command `id` with `params` (`{}` for none) through the real +/// menu command dispatcher, which handles rename's in-place editor, opens a command's dialog where +/// it has one and does other shell routing. Greyed when `params` is `None` (nothing to act on) or +/// where the menu bar greys the command. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str, params: Option) { + let on = params.is_some() && crate::menus::is_enabled(app, id); + if ui.add_enabled(on, egui::Button::new(label)).clicked() + && let Some(params) = params + { let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index ba20b0bfba..15c602ae99 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -1,6 +1,6 @@ use super::brush_controls::{brush_preset_chip, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown}; use super::properties::{select_and_mask_button, selection_mode_buttons}; -use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, vec2, widgets}; +use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, starts_blend_group, vec2, widgets}; use photocraft_paint::BrushSettings; // Custom controls also need to bring keyboard focus into a scroll viewport. @@ -449,7 +449,9 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); // The wheel over it steps the mode, as over the Layers panel's (#2061). - widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + widgets::dropdown_wheel_grouped(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0, |m| { + starts_blend_group(*m) + }); opt_label(ui, tl!("Opacity")); let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); // Live while scrubbed, one undo step per scrub (#5012). diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index ebddecde2c..6029ae52b4 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -1206,6 +1206,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::histogram_panel::panel_menu(app, ui); ui.separator(); } + "channels" => { + crate::channels_panel::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index fb2fb9bd79..886e6f0f8a 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -763,6 +763,46 @@ fn actions_panel_menu_includes_load_and_save_actions() { assert!(menu.query_all_by_label("New Action…").count() > 0); } +/// The Channels panel menu (☰) has the channel items, not only the rows' context menu (#11451): +/// New Spot Channel… and New Channel… open their dialogs, and OK in New Channel adds a channel. +#[test] +fn channels_panel_menu_opens_new_spot_and_adds_a_channel() { + use egui_kittest::kittest::NodeT; + let mut app = self::app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut ready = false; + let mut menu = Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "channels"); + }, + app, + ); + menu.run_steps(2); + let disabled = |menu: &Harness<'static, PhotocraftApp>, label: &str| menu.get_by_label(label).accesskit_node().is_disabled(); + // The composite is targeted: nothing to duplicate, delete or give options to. + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(disabled(&menu, label), "{label} waits for a targeted channel"); + } + menu.get_by_label("New Spot Channel…").click(); + menu.run_steps(2); + let dialogs = &menu.state().ui.dialogs; + assert!(dialogs.iter().any(|d| crate::channel_options_ui::owns_new_spot(&d.fields)), "New Spot Channel… opens its dialog"); + menu.state_mut().ui.dialogs.clear(); + menu.get_by_label("New Channel…").click(); + menu.run_steps(2); + assert!(menu.state().session.active().unwrap().doc.channels.is_empty(), "New Channel… asks first"); + let dialog = menu.state().ui.dialogs.last().expect("the New Channel dialog"); + assert_eq!(dialog.fields["__label"], json!("New Channel…")); + let id = dialog.id; + crate::dialogs::confirm(menu.state_mut(), id).unwrap(); + assert_eq!(menu.state().session.active().unwrap().doc.channels.len(), 1, "OK adds an alpha channel"); + assert!(!menu.state().ui.status_error, "{}", menu.state().ui.status); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index c8f62a602e..839e461177 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -6,7 +6,7 @@ use super::{Align2, Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, StrokeKin /// Tool groups separated by hairlines, Photoshop order. /// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button /// shows the group's last-used tool. -pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ +pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Move, Tool::Artboard]], &[ &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee], @@ -17,8 +17,8 @@ pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Eyedropper, Tool::ColorSampler, Tool::Ruler, Tool::Note, Tool::Count], ], &[ - &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], - &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement], + &[Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], + &[Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush], &[Tool::CloneStamp, Tool::PatternStamp], &[Tool::HistoryBrush, Tool::ArtHistoryBrush], &[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser], @@ -46,11 +46,17 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool /// Next tool in a toolbar flyout slot after `from`, wrapping (Photoshop Alt/Option-click). An /// empty slot returns `from` unchanged rather than dividing by zero. -pub(super) fn next_slot_tool(slot: &[Tool], from: Tool) -> Tool { +pub(crate) fn next_slot_tool(slot: &[Tool], from: Tool) -> Tool { let i = slot.iter().position(|&t| t == from).unwrap_or(0); i.checked_add(1).and_then(|n| n.checked_rem(slot.len())).and_then(|n| slot.get(n)).copied().unwrap_or(from) } +/// Position of `tool` in [`TOOL_SECTIONS`] read top to bottom, slot by slot. Tools outside the +/// toolbar sort last. Shift+letter orders a key's group by this so it steps like the flyout. +pub(crate) fn toolbar_position(tool: Tool) -> usize { + TOOL_SECTIONS.iter().flat_map(|section| section.iter()).flat_map(|slot| slot.iter()).position(|&t| t == tool).unwrap_or(usize::MAX) +} + /// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the /// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A /// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is diff --git a/crates/ui-egui/src/panels/type_flyout_tests.rs b/crates/ui-egui/src/panels/type_flyout_tests.rs index 5144379a3e..9e13f66169 100644 --- a/crates/ui-egui/src/panels/type_flyout_tests.rs +++ b/crates/ui-egui/src/panels/type_flyout_tests.rs @@ -293,8 +293,8 @@ fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); assert!(j.contains(&Tool::RedEye), "{j:?}"); assert_eq!(j.last(), Some(&Tool::RedEye)); - // Remove leads the flyout, so the slot shows it until another J tool is picked. - assert_eq!(j.first(), Some(&Tool::Remove)); + // Spot Healing Brush leads the flyout, so the slot shows it until another J tool is picked. + assert_eq!(j.first(), Some(&Tool::SpotHealing)); } #[test] @@ -348,8 +348,8 @@ fn long_press_brush_selects_color_replacement() { frame(&mut app, &ctx, 0.1, vec![]); let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); - // Photoshop's Brush group, Color Replacement after the Mixer Brush. - assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); + // Photoshop's Brush group, Color Replacement before the Mixer Brush. + assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush]); let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; let mut buttons: Vec = ctx.viewport(|v| { v.prev_pass @@ -369,8 +369,8 @@ fn long_press_brush_selects_color_replacement() { frame(&mut app, &ctx, 1.45, vec![]); let key = egui::Id::unique(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); - // The last of the four 26-point rows. - let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0); + // The third of the four 26-point rows. + let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 46.0); frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); assert_eq!(app.ui.tool, Tool::ColorReplacement); diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index aea5743215..6cc618e8fa 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -259,7 +259,7 @@ fn the_eraser_in_block_mode_erases_a_16_screen_px_square() { } #[test] -fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { +fn b_cycles_brush_pencil_color_replacement_and_mixer_brush() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.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(), Default::default()); @@ -279,9 +279,9 @@ fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); - press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + press(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); // Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool. h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); press(&mut h, Modifiers::SHIFT); @@ -289,11 +289,11 @@ fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { press(&mut h, Modifiers::SHIFT); assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::SHIFT); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); press(&mut h, Modifiers::SHIFT); - assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); } diff --git a/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs b/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs new file mode 100644 index 0000000000..cedd61c69e --- /dev/null +++ b/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs @@ -0,0 +1,192 @@ +//! Polygonal Lasso History step and Shift snap (#11393): the step is named "Polygonal Lasso" +//! however the polygon closes, and a Shift diagonal keeps the shorter of the two offsets on both +//! axes, as Photoshop does. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, constrained_polygon_point, tool_event}; +use crate::state::Tool; + +fn app_with(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool = tool; + app +} + +fn ev(app: &mut PhotocraftApp, e: ToolEvent, m: Modifiers) { + tool_event(app, e, m); +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + ev(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + ev(app, ToolEvent::Up { x, y }, m); +} + +fn last_step(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() +} + +fn bounds(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()) +} + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = app_with(Tool::PolygonLasso); + app.ui.extras.rulers = false; + // 60 fps, so a double-click's two clicks fall inside egui's 0.3 s window. + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // 100 %: one document pixel per point, the area around (200, 200) in view. + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 200.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +fn screen_click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } +} + +/// Three corners, well apart in time so they aren't a double-click. +fn three_corners(h: &mut Harness<'static, PhotocraftApp>) { + for (x, y) in [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0)] { + screen_click(h, x, y); + h.run_steps(30); + } +} + +#[test] +fn closing_on_the_start_point_records_a_polygonal_lasso_step() { + let mut app = app_with(Tool::PolygonLasso); + for (x, y) in [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 100.0)] { + click(&mut app, x, y, Modifiers::NONE); + } + assert!(app.ui.polygon.is_empty(), "the start point closes the polygon"); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); + assert_eq!(last_step(&app).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn closing_with_enter_records_a_polygonal_lasso_step() { + let mut h = harness(); + three_corners(&mut h); + let before = h.state().session.active().unwrap().history.entries().len(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().ui.polygon.is_empty(), "Enter closes the polygon"); + assert!(bounds(h.state()).is_some()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), before + 1, "one step"); + assert_eq!(last_step(h.state()).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn closing_with_a_double_click_records_a_polygonal_lasso_step() { + let mut h = harness(); + three_corners(&mut h); + screen_click(&mut h, 100.0, 300.0); + screen_click(&mut h, 100.0, 300.0); + h.run_steps(1); + assert!(h.state().ui.polygon.is_empty(), "the double-click closes the polygon"); + assert!(bounds(h.state()).is_some()); + assert_eq!(last_step(h.state()).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn the_freehand_lasso_still_records_a_lasso_step() { + let mut app = app_with(Tool::Lasso); + ev(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(200.0, 100.0), (300.0, 100.0), (300.0, 200.0), (300.0, 300.0), (200.0, 300.0)] { + ev(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE); + } + ev(&mut app, ToolEvent::Up { x: 200.0, y: 300.0 }, Modifiers::NONE); + assert!(bounds(&app).is_some()); + assert_eq!(last_step(&app).as_deref(), Some("Lasso")); + // And the engine's own default, for scripts that leave `polygonal` out. + app.run("select.lasso", json!({"points": [[10, 10], [60, 10], [10, 60]]})).unwrap(); + assert_eq!(last_step(&app).as_deref(), Some("Lasso")); +} + +/// The reference measurement: from 100,100 a Shift-click at 200,170 lands on (170,170), and the +/// closed selection's right edge is at 170. +#[test] +fn a_shift_click_at_200_170_from_100_100_places_the_vertex_at_170_170() { + let mut app = app_with(Tool::PolygonLasso); + click(&mut app, 100.0, 100.0, Modifiers::NONE); + click(&mut app, 200.0, 170.0, Modifiers::SHIFT); + assert_eq!(app.ui.polygon, vec![[100.0, 100.0], [170.0, 170.0]]); + click(&mut app, 100.0, 300.0, Modifiers::NONE); + crate::canvas::commit_polygon(&mut app); + assert_eq!(bounds(&app).map(|r| (r.x0, r.x1)), Some((100, 170)), "{:?}", bounds(&app)); + assert_eq!(last_step(&app).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn each_diagonal_keeps_the_shorter_offset_with_its_signs() { + let from = [100.0, 100.0]; + // One case per diagonal, the shorter offset on either axis. + for (to, want) in [ + ([200.0, 170.0], [170.0, 170.0]), // down-right, dy shorter + ([30.0, 200.0], [30.0, 170.0]), // down-left, dx shorter + ([0.0, 30.0], [30.0, 30.0]), // up-left, dy shorter + ([170.0, 0.0], [170.0, 30.0]), // up-right, dx shorter + ] { + assert_eq!(constrained_polygon_point(from, to), want, "{to:?}"); + } +} + +/// Axis snaps aren't measured in the reference, so they keep today's rule: the segment's length. +#[test] +fn horizontal_and_vertical_snaps_keep_the_segment_length() { + let from = [100.0, 100.0]; + let length = 100.0_f64.hypot(20.0); + for (to, want) in [ + ([200.0, 120.0], [100.0 + length, 100.0]), + ([80.0, 200.0], [100.0, 100.0 + length]), + ([0.0, 80.0], [100.0 - length, 100.0]), + ([120.0, 0.0], [100.0, 100.0 - length]), + ] { + let got = constrained_polygon_point(from, to); + assert!((got[0] - want[0]).abs() < 1e-9 && (got[1] - want[1]).abs() < 1e-9, "{to:?} → {got:?}"); + } +} diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index f3f713ed6f..55fb30ca30 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -635,7 +635,8 @@ fn shift_constrains_polygonal_lasso_segments_but_unmodified_clicks_remain_free() ev(&mut app, "up", 140.0, 120.0, Modifiers::SHIFT); let diagonal = app.ui.polygon[1]; assert!((diagonal[0] - diagonal[1]).abs() < 1e-8, "45-degree direction: {diagonal:?}"); - assert!((diagonal[0] - 100.0 - 40.0_f64.hypot(20.0) / 2.0_f64.sqrt()).abs() < 1e-8); + // The diagonal keeps the shorter offset (20) on both axes (#11393). + assert!((diagonal[0] - 120.0).abs() < 1e-8, "{diagonal:?}"); // Releasing Shift restores free placement rather than constraining subsequent points. ev(&mut app, "down", 155.0, 175.0, Modifiers::NONE); ev(&mut app, "up", 155.0, 175.0, Modifiers::NONE); @@ -652,7 +653,9 @@ fn shift_polygonal_lasso_snap_has_all_eight_directions_and_handles_zero_length() let dy = end[1] - origin[1]; let angle = dy.atan2(dx) / std::f64::consts::FRAC_PI_4; assert!((angle - angle.round()).abs() < 1e-9, "{delta:?} → {end:?}"); - assert!((dx.hypot(dy) - delta[0].hypot(delta[1])).abs() < 1e-8); + // Axis snaps keep the length; diagonals keep the shorter offset on both axes (#11393). + let kept = if angle.round().rem_euclid(2.0) == 1.0 { delta[0].abs().min(delta[1].abs()) * std::f64::consts::SQRT_2 } else { delta[0].hypot(delta[1]) }; + assert!((dx.hypot(dy) - kept).abs() < 1e-8, "{delta:?} → {end:?}"); } assert_eq!(crate::canvas::constrained_polygon_point(origin, origin), origin); } diff --git a/crates/ui-egui/src/preferences_submenu_tests.rs b/crates/ui-egui/src/preferences_submenu_tests.rs new file mode 100644 index 0000000000..65b54dc4eb --- /dev/null +++ b/crates/ui-egui/src/preferences_submenu_tests.rs @@ -0,0 +1,87 @@ +//! #11398: Edit › Preferences says "Plugins…" and "Camera Raw…" and has the reference +//! application's two separators (after the first entry and before Camera Raw…). + +use crate::menu_catalog::CATALOG; + +/// The submenu's rows, top to bottom: (label or "---", command id). +fn rows() -> Vec<(&'static str, &'static str)> { + CATALOG.iter().filter(|e| e.0 == ["Edit", "Preferences"]).map(|e| (e.1, e.3)).collect() +} + +#[test] +fn preferences_submenu_reads_in_the_reference_order_with_two_separators() { + let labels: Vec<&str> = rows().into_iter().map(|r| r.0).collect(); + // Row 1 ("Settings…") is #8239's: whatever it says, it is first and followed by a separator. + assert_eq!( + &labels[1..], + [ + "---", + "AI Integrations…", + "Interface…", + "Workspace…", + "Tools…", + "History Log…", + "File Handling…", + "Export…", + "Performance…", + "Scratch Disks…", + "Cursors…", + "Transparency & Gamut…", + "Units & Rulers…", + "Guides, Grid & Slices…", + "Plugins…", + "Type…", + "Enhanced Controls…", + "---", + "Camera Raw…", + ] + ); +} + +#[test] +fn preferences_submenu_ids_are_unchanged() { + let ids: Vec<&str> = rows().into_iter().map(|r| r.1).collect(); + assert_eq!( + ids, + [ + "edit.preferences.general", + "---", + "edit.preferences.integrations", + "edit.preferences.interface", + "edit.preferences.workspace", + "edit.preferences.tools", + "edit.preferences.historyLog", + "edit.preferences.fileHandling", + "edit.preferences.export", + "edit.preferences.performance", + "edit.preferences.scratchDisks", + "edit.preferences.cursors", + "edit.preferences.transparencyAndGamut", + "edit.preferences.unitsAndRulers", + "edit.preferences.guidesGridAndSlices", + "edit.preferences.plugIns", + "edit.preferences.type", + "edit.preferences.enhancedControls", + "---", + "edit.preferences.rawDefaults", + ] + ); +} + +#[test] +fn engine_labels_match_the_catalogue_for_the_renamed_rows() { + for id in ["edit.preferences.plugIns", "edit.preferences.rawDefaults"] { + let catalogue = CATALOG.iter().find(|e| e.3 == id).unwrap_or_else(|| panic!("{id} missing from the catalogue")).1; + let engine = photocraft_engine::commands::find(id).unwrap_or_else(|| panic!("{id} missing from the engine")).label; + assert_eq!(engine, catalogue, "{id}"); + } + assert_eq!(photocraft_engine::commands::find("edit.preferences.plugIns").unwrap().label, "Plugins…"); + assert_eq!(photocraft_engine::commands::find("edit.preferences.rawDefaults").unwrap().label, "Camera Raw…"); +} + +#[test] +fn preferences_dialog_page_names_follow_the_menu() { + let title = |key: &str| photocraft_engine::prefs::SECTIONS.iter().find(|(k, _)| *k == key).map(|(_, t)| *t); + assert_eq!(title("plugIns"), Some("Plugins")); + assert_eq!(title("rawDefaults"), Some("Camera Raw Defaults")); +} diff --git a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs index f59bf278e8..20a2695a97 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs @@ -106,7 +106,7 @@ fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { press(&mut app, Key::B, Modifiers::NONE); assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory"); press(&mut app, Key::B, Modifiers::SHIFT); - assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool"); + assert_eq!(app.ui.tool, Tool::ColorReplacement, "Shift cycling skips a removed tool"); app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); app.ui.tool = Tool::Brush; @@ -114,7 +114,7 @@ fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group"); app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); press(&mut app, Key::B, Modifiers::NONE); - assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference"); + assert_eq!(app.ui.tool, Tool::ColorReplacement, "plain-key cycling still follows the preference"); } #[test] diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index f23a440d4d..f59464d6d1 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -298,6 +298,56 @@ fn save_settings_start_at_the_opened_files_encoding() { assert!(app.save_settings().webp_lossless); } +/// The names of the active document's History panel snapshots. +fn snapshots(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect() +} + +/// History Options › Automatically Create New Snapshot When Saving (#2966) through the desktop +/// save path: a save adds one snapshot, saving again unchanged adds none, Save a Copy adds none. +#[test] +fn a_save_adds_one_snapshot_and_a_copy_none() { + let mut app = app(); + app.run("history.options", json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + let before = snapshots(&app).len(); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "the save adds a snapshot"); + let st = app.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.snapshots.last().unwrap().doc, &st.doc), "of the document as saved"); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "saving an unchanged document adds no duplicate"); + + app.run("layer.new.layer", json!({})).unwrap(); + app.write_document("copy.psd".into(), &ExportSettings::default(), true).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "Save a Copy adds none"); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 2); +} + +/// A background save that finishes while another document is active snapshots the document it +/// saved, not the active one. +#[test] +fn a_background_save_snapshots_its_own_document() { + let mut app = app(); + app.run("history.options", json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + let (saved_doc, revision) = { + let st = app.session.active().unwrap(); + (st.doc.id, st.revision) + }; + let saved_snaps = snapshots(&app).len(); + app.run("file.new", json!({"width": 4, "height": 4, "name": "Untitled-2"})).unwrap(); + let active = app.active_doc_id().unwrap(); + assert_ne!(active, saved_doc); + let active_snaps = snapshots(&app).len(); + // What jobs_ui does when the save job of `saved_doc` ends. + app.saved(saved_doc, revision, "background.psd", &[], false); + assert_eq!(app.active_doc_id(), Ok(active), "the active document stays active"); + assert_eq!(snapshots(&app).len(), active_snaps, "the active document gets no snapshot"); + let st = app.session.documents().iter().find(|st| st.doc.id == saved_doc).unwrap(); + assert_eq!(st.snapshots.len(), saved_snaps + 1, "the saved document gets one"); + assert_eq!(st.path.as_deref(), Some("background.psd")); +} + /// A browser download is only requested: the document takes the name but keeps its unsaved /// changes, so the leave-page guard stays armed (#4962). A confirmed write still saves it. #[test] diff --git a/crates/ui-egui/src/selection_move_step_tests.rs b/crates/ui-egui/src/selection_move_step_tests.rs new file mode 100644 index 0000000000..5c3ec336fd --- /dev/null +++ b/crates/ui-egui/src/selection_move_step_tests.rs @@ -0,0 +1,99 @@ +//! #11390: dragging the selection outline with a marquee or lasso records "Move Selection", as the +//! reference application does; Select > Transform Selection keeps "Transform Selection". + +use egui::Modifiers; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 400 x 300 document with 100,100 to 300,300 selected and `tool` active. +fn app_with_selection(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool = tool; + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + app +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +fn bounds(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()) +} + +fn last_step(app: &PhotocraftApp) -> String { + app.session.active().unwrap().history.entries().last().cloned().unwrap() +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn check_drag_records_move_selection(tool: Tool) { + let mut app = app_with_selection(tool); + let before = past(&app); + drag(&mut app, [200.0, 200.0], [300.0, 250.0]); + assert_eq!(bounds(&app), Some(Rect::new(200, 150, 400, 350)), "moved by +100,+50"); + assert_eq!(past(&app), before + 1, "one step"); + assert_eq!(last_step(&app), "Move Selection"); +} + +#[test] +fn rectangular_marquee_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::RectMarquee); +} + +#[test] +fn elliptical_marquee_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::EllipseMarquee); +} + +#[test] +fn lasso_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::Lasso); +} + +#[test] +fn undo_after_the_drag_restores_the_outline() { + let mut app = app_with_selection(Tool::RectMarquee); + drag(&mut app, [200.0, 200.0], [300.0, 250.0]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); +} + +#[test] +fn transform_selection_keeps_its_step_name() { + let mut app = app_with_selection(Tool::RectMarquee); + app.run("select.transformSelection", json!({"dx": 100, "dy": 50})).unwrap(); + assert_eq!(bounds(&app), Some(Rect::new(200, 150, 400, 350))); + assert_eq!(last_step(&app), "Transform Selection"); +} + +#[test] +fn a_click_without_moving_still_deselects() { + let mut app = app_with_selection(Tool::RectMarquee); + drag(&mut app, [200.0, 200.0], [200.0, 200.0]); + assert_eq!(bounds(&app), None); + assert_eq!(last_step(&app), "Deselect"); +} + +#[test] +fn an_unknown_step_name_is_refused() { + let mut app = app_with_selection(Tool::RectMarquee); + let before = past(&app); + for name in [json!("Delete Layer"), json!(""), json!(3)] { + assert!(app.run("select.transformSelection", json!({"dx": 10, "dy": 0, "stepName": name})).is_err()); + } + assert_eq!(past(&app), before); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); +} diff --git a/crates/ui-egui/src/shape_bounds_box_tests.rs b/crates/ui-egui/src/shape_bounds_box_tests.rs new file mode 100644 index 0000000000..7e4dbb8902 --- /dev/null +++ b/crates/ui-egui/src/shape_bounds_box_tests.rs @@ -0,0 +1,325 @@ +//! #11107: the box round several selected shape layers (Path Selection and the shape tools), its +//! handles scaling them, measured against the reference application: two rectangles +//! 100,98–403,301 and 420,318–723,451 scaled by dragging the bottom-right handle by (−100, −60). + +use egui::Modifiers; +use photocraft_doc::LayerContent; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, tool_event}; +use crate::shape_bounds_box::scale_for; +use crate::state::Tool; + +const NONE: Modifiers = Modifiers::NONE; + +/// A 1:1 view of the 1000 × 700 document, turned by `rotation` degrees. +fn view(rotation: f32) -> ViewXform { + ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 700.0)), + zoom: 1.0, + center: [500.0, 350.0], + flip: false, + rotation, + aspect: 1.0, + } +} + +fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { + crate::shape_bounds_box::cursor(app, &view(0.0), p) +} + +/// A 1000 × 700 document with the two measured rectangles, both selected. +fn app_with_two_shapes(tool: Tool) -> (PhotocraftApp, u64, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 700, "background": "white"})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [100, 98, 303, 203], "fill": "#e02020"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [420, 318, 303, 133], "fill": "#2050e0"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": a.0})).unwrap(); + app.run("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap(); + app.ui.tool = tool; + (app, a.0, b.0) +} + +fn bounds(app: &PhotocraftApp, id: u64) -> (f64, f64, f64, f64) { + let st = app.session.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { panic!("a shape layer") }; + sh.path.control_bounds().unwrap() +} + +fn near(got: (f64, f64, f64, f64), want: [f64; 4]) { + let got = [got.0, got.1, got.2, got.3]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0), "{got:?} vs {want:?}"); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, NONE); + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, NONE); +} + +#[test] +fn the_measured_drag_gives_the_reference_bounds_with_every_tool() { + for tool in [Tool::Rectangle, Tool::PathSelection, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] { + let (mut app, a, b) = app_with_two_shapes(tool); + let steps = app.session.active().unwrap().history.past_len(); + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + near(bounds(&app, b), [368.0, 282.0, 621.0, 394.0]); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "{tool:?}: one history step"); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Transform Path"), "{tool:?}"); + assert!(app.ui.transform.is_none() && app.ui.shape_box.is_none(), "{tool:?}: no Free Transform session opens"); + // The shapes stay live. + for id in [a, b] { + let LayerContent::Shape(sh) = &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { panic!() }; + assert!(sh.live.is_some(), "{tool:?}: still a live shape"); + } + } +} + +#[test] +fn an_edge_handle_scales_one_way_and_undo_restores_both_shapes() { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + // The right edge's middle, dragged 100 px left: x scales about the left edge (100). + drag(&mut app, [723.0, 274.5], [623.0, 274.5]); + let s = 523.0 / 623.0; + near(bounds(&app, a), [100.0, 98.0, 100.0 + 303.0 * s, 301.0]); + near(bounds(&app, b), [100.0 + 320.0 * s, 318.0, 100.0 + 623.0 * s, 451.0]); + app.run("edit.undo", json!({})).unwrap(); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); +} + +#[test] +fn a_click_without_movement_changes_nothing() { + let (mut app, ..) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + drag(&mut app, [723.0, 451.0], [723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert!(app.ui.shape_box.is_none()); +} + +#[test] +fn one_selected_shape_gets_the_box_and_other_tools_none() { + // Photoshop shows the box for a single shape too: its corner handle scales that shape. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + app.run("layer.select", json!({"layer": a})).unwrap(); + assert_eq!(cursor(&app, [403.0, 301.0]), Some(egui::CursorIcon::ResizeNwSe)); + assert!(cursor(&app, [723.0, 451.0]).is_none(), "the other shape's corner is not a handle"); + drag(&mut app, [403.0, 301.0], [303.0, 241.0]); + let s = 1.0 + (-100.0 * 303.0 + -60.0 * 203.0) / (303.0f64 * 303.0 + 203.0 * 203.0); + near(bounds(&app, a), [100.0, 98.0, 100.0 + 303.0 * s, 98.0 + 203.0 * s]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.entries().last().map(String::as_str), Some("Transform Path")); + + // Another tool has none. + let (mut app, ..) = app_with_two_shapes(Tool::Move); + assert!(cursor(&app, [723.0, 451.0]).is_none()); + app.ui.tool = Tool::Brush; + assert!(cursor(&app, [723.0, 451.0]).is_none()); +} + +#[test] +fn the_handles_show_their_scale_cursors() { + use egui::CursorIcon::{ResizeHorizontal, ResizeNeSw, ResizeNwSe, ResizeVertical}; + let (app, ..) = app_with_two_shapes(Tool::PathSelection); + let at = |p: [f64; 2]| cursor(&app, p); + assert_eq!(at([100.0, 98.0]), Some(ResizeNwSe)); + assert_eq!(at([723.0, 451.0]), Some(ResizeNwSe)); + assert_eq!(at([723.0, 98.0]), Some(ResizeNeSw)); + assert_eq!(at([100.0, 451.0]), Some(ResizeNeSw)); + assert_eq!(at([411.5, 98.0]), Some(ResizeVertical)); + assert_eq!(at([723.0, 274.5]), Some(ResizeHorizontal)); + assert_eq!(at([411.5, 274.5]), None, "inside the box the tool's own arrow stays, and outside it nothing rotates"); + assert_eq!(at([760.0, 480.0]), None); +} + +#[test] +fn a_corner_radius_widget_wins_over_the_box() { + // The widget of the second rectangle's bottom-right corner sits inside the corner, not on the + // box's handle: a press on it rounds the corners, no scale starts. + let (mut app, ..) = app_with_two_shapes(Tool::Rectangle); + let widget = [713.0, 441.0]; + tool_event(&mut app, ToolEvent::Down { x: widget[0], y: widget[1], pressure: 1.0 }, NONE); + assert!(app.ui.corner_radius.is_some() && app.ui.shape_box.is_none()); + tool_event(&mut app, ToolEvent::Up { x: widget[0], y: widget[1] }, NONE); + // A press right on the handle scales, not rounds. + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some() && app.ui.corner_radius.is_none()); +} + +#[test] +fn scale_for_projects_a_corner_drag_onto_the_diagonal() { + let r = [100.0, 98.0, 723.0, 451.0]; + // The measured drag: both ways by the same factor, (−100, −60) on this 623 × 353 box. + let [sx, sy] = scale_for(r, [1, 1], [-100.0, -60.0]); + assert_eq!(sx, sy); + assert!((sx - 0.8372).abs() < 1e-3, "{sx}"); + // The top-left corner pulls the other way. + let [s, _] = scale_for(r, [-1, -1], [100.0, 60.0]); + assert!((s - 0.8372).abs() < 1e-3, "{s}"); + // A drag along the other diagonal changes nothing. + assert_eq!(scale_for(r, [1, 1], [353.0, -623.0]), [1.0, 1.0]); + // Edges scale one way; the box can't be flipped or blown up without bound. + assert_eq!(scale_for(r, [1, 0], [-100.0, 500.0]), [1.0 - 100.0 / 623.0, 1.0]); + assert_eq!(scale_for(r, [0, 1], [50.0, -60.0]), [1.0, 1.0 - 60.0 / 353.0]); + assert_eq!(scale_for(r, [1, 0], [-9000.0, 0.0]), [0.01, 1.0]); + assert_eq!(scale_for(r, [1, 1], [f64::NAN, 0.0]), [1.0, 1.0]); + assert_eq!(scale_for(r, [0, 0], [10.0, 10.0]), [1.0, 1.0]); +} + +#[test] +fn a_rotated_view_turns_the_handle_cursors() { + use egui::CursorIcon::{ResizeHorizontal, ResizeNeSw, ResizeNwSe, ResizeVertical}; + let (app, ..) = app_with_two_shapes(Tool::PathSelection); + let at = |rotation: f32, p: [f64; 2]| crate::shape_bounds_box::cursor(&app, &view(rotation), p); + assert_eq!(at(0.0, [723.0, 451.0]), Some(ResizeNwSe)); + // A quarter turn swaps the diagonals and the edge arrows. + assert_eq!(at(90.0, [723.0, 451.0]), Some(ResizeNeSw)); + assert_eq!(at(90.0, [723.0, 98.0]), Some(ResizeNwSe)); + assert_eq!(at(90.0, [723.0, 274.5]), Some(ResizeVertical)); + assert_eq!(at(90.0, [411.5, 98.0]), Some(ResizeHorizontal)); +} + +#[test] +fn hidden_paths_hide_the_box_and_its_handles_take_no_press() { + // View › Extras off, or View › Show › Target Path off. + for (extras, target_path) in [(false, true), (true, false)] { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + app.ui.view.extras = extras; + app.ui.view.show.target_path = target_path; + assert!(cursor(&app, [723.0, 451.0]).is_none(), "extras {extras}"); + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "extras {extras}: no scale drag starts"); + tool_event(&mut app, ToolEvent::Up { x: 723.0, y: 451.0 }, NONE); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + } +} + +/// Press the bottom-right handle and drag it, without releasing. +fn press_and_drag(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Move { x: 623.0, y: 391.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some()); +} + +fn release_changes_nothing(app: &mut PhotocraftApp, a: u64, b: u64, steps: usize, why: &str) { + tool_event(app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "{why}"); + near(bounds(app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "{why}: no history step"); +} + +#[test] +fn escape_or_a_tool_change_cancels_a_handle_drag() { + // Esc. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + // Through the shortcut handler, as the key arrives in the app. + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert!(app.ui.shape_box.is_none()); + release_changes_nothing(&mut app, a, b, steps, "Esc"); + // The next press works again. + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + + // Another tool, picked mid-drag. + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.ui.tool = Tool::Brush; + app.sync_views(); + assert!(app.ui.shape_box.is_none()); + release_changes_nothing(&mut app, a, b, steps, "tool change"); + + // Even without a frame between the change and the release. + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.ui.tool = Tool::Move; + release_changes_nothing(&mut app, a, b, steps, "tool change, same frame"); +} + +#[test] +fn a_document_switch_or_an_undo_cancels_a_handle_drag() { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.run("file.new", json!({"width": 1000, "height": 700, "background": "white"})).unwrap(); + app.sync_views(); + assert!(app.ui.shape_box.is_none()); + let other = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert_eq!(app.session.active().unwrap().history.past_len(), other, "nothing lands in the other document"); + assert!(app.session.set_active(0)); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // An edit under the drag (Undo here): the release applies nothing to the changed shapes. + let (mut app, ..) = app_with_two_shapes(Tool::PathSelection); + press_and_drag(&mut app); + app.run("edit.undo", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert!(app.ui.shape_box.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// With background jobs on, as in the desktop app, `edit.transform` lands after `run` returns. +/// The scale is still one step named "Transform Path", and the step before it (the second +/// shape's "New Shape Layer") keeps its name. Renaming the top step after `run` named the +/// shape's step "Transform Path" and left the scale as "Free Transform". +#[test] +fn with_background_jobs_the_scale_step_is_named_and_the_one_before_is_kept() { + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + app.background_jobs = true; + let before: Vec = app.session.active().unwrap().history.entries().to_vec(); + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + let ctx = egui::Context::default(); + for _ in 0..2000 { + crate::jobs_ui::tick(&mut app, &ctx); + if !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!app.session.has_jobs(), "the transform job never landed"); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + near(bounds(&app, b), [368.0, 282.0, 621.0, 394.0]); + let after: Vec = app.session.active().unwrap().history.entries().to_vec(); + assert_eq!(after.len(), before.len() + 1, "one history step: {after:?}"); + assert_eq!(after[..before.len()], before[..], "the earlier steps keep their names"); + assert_eq!(after.last().map(String::as_str), Some("Transform Path")); +} + +/// A switch to another tool during a handle drag ends the drag, also when the new tool shows the +/// box too; the release scales nothing and is still the box's (it doesn't reach the new tool). +#[test] +fn a_tool_switch_during_a_drag_ends_it_and_takes_its_release() { + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + let (ba, bb) = (bounds(&app, a), bounds(&app, b)); + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: 623.0, y: 391.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some()); + app.ui.tool = Tool::PathSelection; + assert!(crate::shape_bounds_box::pointer(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE), "the release is the box's"); + assert!(app.ui.shape_box.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!((bounds(&app, a), bounds(&app, b)), (ba, bb)); +} diff --git a/crates/ui-egui/src/single_line_marquee_move_tests.rs b/crates/ui-egui/src/single_line_marquee_move_tests.rs new file mode 100644 index 0000000000..01c2286137 --- /dev/null +++ b/crates/ui-egui/src/single_line_marquee_move_tests.rs @@ -0,0 +1,367 @@ +//! Single Row / Single Column Marquee drags through the real canvas (#11392): a drag that starts +//! inside the selection moves it, as the other marquees' does; one that starts outside, a click +//! inside, and a ⇧ / ⌥ press still select the line under the release. + +use egui::Modifiers; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// An 800 × 600 document with snapping off, so the drags land where they are sent. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.ui.extras.snap = false; + app +} + +/// A drag with `tool` from `a` to `b`, as the canvas sends it. +fn drag(app: &mut PhotocraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], m: Modifiers) { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m); + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: b[0], y: b[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: b[0], y: b[1] }, m); +} + +fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2], m: Modifiers) { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, m); +} + +fn bounds(app: &PhotocraftApp) -> Rect { + app.session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()) +} + +fn selected(app: &PhotocraftApp, x: i32, y: i32) -> bool { + app.session.active().unwrap().doc.selection.as_ref().is_some_and(|s| s.sample_channel(x, y, 0) > 0.5) +} + +fn step(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.undo_label().map(str::to_string) +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +/// Rows 150 and 250 (New, then ⇧ adds). +fn two_rows(app: &mut PhotocraftApp) { + click(app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(app, Tool::SingleRowMarquee, [200.0, 150.5], Modifiers::SHIFT); + assert_eq!(bounds(app), Rect::new(0, 150, 800, 251)); +} + +/// Columns 200 and 300. +fn two_columns(app: &mut PhotocraftApp) { + click(app, Tool::SingleColumnMarquee, [300.5, 250.0], Modifiers::NONE); + click(app, Tool::SingleColumnMarquee, [200.5, 150.0], Modifiers::SHIFT); + assert_eq!(bounds(app), Rect::new(200, 0, 301, 600)); +} + +/// What the Rectangular Marquee's move of the same selection gives: where the part moved past the +/// canvas goes, and the History step's name. +fn rect_marquee_move(select: impl Fn(&mut PhotocraftApp), a: [f64; 2], b: [f64; 2]) -> (Rect, Option) { + let mut app = app(); + select(&mut app); + drag(&mut app, Tool::RectMarquee, a, b, Modifiers::NONE); + (bounds(&app), step(&app)) +} + +#[test] +fn single_row_drag_from_inside_moves_the_selection() { + let mut app = app(); + two_rows(&mut app); + let before = steps(&app); + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [400.0, 320.5], Modifiers::NONE); + assert!(selected(&app, 450, 220) && selected(&app, 450, 320), "both rows moved by +100,+70"); + assert!(!selected(&app, 450, 150) && !selected(&app, 450, 250), "the old rows are gone"); + assert!(!selected(&app, 50, 220), "moved right by 100"); + assert_eq!(steps(&app), before + 1, "one step"); + assert_eq!((bounds(&app), step(&app)), rect_marquee_move(two_rows, [300.0, 250.5], [400.0, 320.5]), "as the Rectangular Marquee moves it"); +} + +#[test] +fn single_column_drag_from_inside_moves_the_selection() { + let mut app = app(); + two_columns(&mut app); + let before = steps(&app); + drag(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0], [400.5, 320.0], Modifiers::NONE); + assert!(selected(&app, 300, 400) && selected(&app, 400, 400), "both columns moved by +100,+70"); + assert!(!selected(&app, 200, 400), "the old column 200 is gone"); + assert!(!selected(&app, 300, 30), "moved down by 70"); + assert_eq!(steps(&app), before + 1, "one step"); + assert_eq!((bounds(&app), step(&app)), rect_marquee_move(two_columns, [300.5, 250.0], [400.5, 320.0]), "as the Rectangular Marquee moves it"); +} + +#[test] +fn drag_from_outside_selects_the_line_at_the_release() { + let mut app = app(); + two_rows(&mut app); + drag(&mut app, Tool::SingleRowMarquee, [100.0, 100.5], [100.0, 200.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 200, 800, 201)); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [100.5, 100.0], [150.5, 100.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(150, 0, 151, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +#[test] +fn click_inside_selects_the_line_under_the_pointer() { + let mut app = app(); + two_rows(&mut app); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251), "row 250 alone, not deselected"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + click(&mut app, Tool::SingleColumnMarquee, [200.5, 150.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(200, 0, 201, 600), "column 200 alone, not deselected"); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// With snapping on, a click inside is still a click when the selection's edge is within reach of +/// the canvas edge: the snapped release moved both rows up 3 px ("Transform Selection") instead, +/// and the Rectangular Marquee's selection with them instead of deselecting. +#[test] +fn click_inside_with_snapping_on_selects_the_line() { + let mut app = app(); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(&mut app, Tool::SingleRowMarquee, [200.0, 3.5], Modifiers::SHIFT); + assert_eq!(bounds(&app), Rect::new(0, 3, 800, 251)); + app.ui.extras.snap = true; + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251)); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 3, "y": 3, "width": 100, "height": 50})).unwrap(); + click(&mut app, Tool::RectMarquee, [50.5, 30.5], Modifiers::NONE); + assert!(app.session.active().unwrap().doc.selection.is_none(), "deselected, not moved onto the edge"); +} + +/// A drag from inside that snapping pulls back to where it started (the selection's edge on a +/// guide, 2 px away) is not a click: the selection stays, with no step. It took the click's path: +/// one line replaced the rows, a floating piece was dropped, and the Rectangular Marquee deselected. +#[test] +fn a_drag_snapped_back_to_its_start_leaves_the_selection() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + app.ui.extras.snap = false; + two_rows(&mut app); + app.ui.extras.snap = true; + let before = steps(&app); + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [300.0, 252.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "both rows where they were"); + assert_eq!(steps(&app), before, "no step"); + + drag(&mut app, Tool::RectMarquee, [300.0, 250.5], [300.0, 252.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "the Rectangular Marquee keeps them too"); + + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + drag(&mut app, Tool::SingleRowMarquee, [200.0, 200.5], [200.0, 202.5], Modifiers::NONE); + assert!(floats(&app), "the piece still floats"); + assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250)); +} + +/// A drag from inside that goes away and comes back to the press is not a click either: the +/// selection stays where it was, with no step. Only the release was checked, so it took the +/// click's path (one line replaced the rows, the piece was dropped, the Rectangular Marquee +/// deselected). +#[test] +fn a_drag_that_returns_to_its_start_leaves_the_selection() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let out_and_back = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: p[0] + 60.0, y: p[1] + 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: p[0] + 0.2, y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: p[0] + 0.2, y: p[1] }, Modifiers::NONE); + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.ui.extras.snap = false; + two_rows(&mut app); + let before = steps(&app); + out_and_back(&mut app, Tool::SingleRowMarquee, [300.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "both rows where they were"); + assert_eq!(steps(&app), before, "no step"); + + two_columns(&mut app); + out_and_back(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0]); + assert_eq!(bounds(&app), Rect::new(200, 0, 301, 600)); + + out_and_back(&mut app, Tool::RectMarquee, [300.5, 250.0]); + assert_eq!(bounds(&app), Rect::new(200, 0, 301, 600), "the Rectangular Marquee keeps them too"); + + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + out_and_back(&mut app, Tool::SingleRowMarquee, [200.0, 200.5]); + assert!(floats(&app), "the piece still floats"); + assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250)); +} + +/// A click whose release drifts under a pixel from the press (a zoomed-in hand jitter) is still a +/// click with snapping on: only an exactly still release ended unsnapped, so the jittered one +/// snapped the rows onto the canvas edge ("Transform Selection") and the Rectangular Marquee's +/// selection with them instead of deselecting. +#[test] +fn a_jittered_click_inside_with_snapping_on_is_still_a_click() { + let jittered_click = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.tool = tool; + let q = [p[0] + 0.2, p[1] - 0.3]; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: q[0], y: q[1] }, Modifiers::NONE); + }; + let mut app = app(); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(&mut app, Tool::SingleRowMarquee, [200.0, 3.5], Modifiers::SHIFT); + app.ui.extras.snap = true; + jittered_click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251), "row 250 alone, not both rows moved up 3 px"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 3, "y": 3, "width": 100, "height": 50})).unwrap(); + jittered_click(&mut app, Tool::RectMarquee, [50.5, 30.5]); + assert!(app.session.active().unwrap().doc.selection.is_none(), "deselected, not moved onto the edge"); +} + +/// With snapping on, a click inside the selection snaps the line onto a guide 2 px away, as a +/// click outside it does; the press began as a move, which kept it on the pointer. +#[test] +fn click_inside_snaps_the_line_to_a_guide() { + let mut app = app(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 252})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 302})).unwrap(); + // The selections are made unsnapped; only the click under test snaps. + let snapped_click = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.extras.snap = true; + click(app, tool, p, Modifiers::NONE); + app.ui.extras.snap = false; + }; + + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 252, 800, 253), "outside the selection: snapped to the guide"); + + two_rows(&mut app); + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 252, 800, 253), "inside: snapped to the guide too"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + snapped_click(&mut app, Tool::SingleColumnMarquee, [300.5, 500.0]); + assert_eq!(bounds(&app), Rect::new(302, 0, 303, 600), "a column inside snaps to the guide"); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); + + // No guide within reach: the line under the pointer, not the selection's own edge. + two_rows(&mut app); + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 150.5]); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 151)); +} + +#[test] +fn combining_drag_from_inside_selects_a_line() { + let mut app = app(); + two_rows(&mut app); + // ⇧ adds the row at the release; nothing moves. + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [400.0, 320.5], Modifiers::SHIFT); + assert!(selected(&app, 10, 150) && selected(&app, 10, 250) && selected(&app, 10, 320)); + // ⌥ subtracts the row under the release. + drag(&mut app, Tool::SingleRowMarquee, [300.0, 320.5], [350.0, 250.5], Modifiers::ALT); + assert!(selected(&app, 10, 150) && !selected(&app, 10, 250) && selected(&app, 10, 320)); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0], [400.5, 320.0], Modifiers::SHIFT); + assert!(selected(&app, 200, 10) && selected(&app, 300, 10) && selected(&app, 400, 10)); +} + +/// Ctrl held at the release turns snapping off for a click inside, as it does during a drag: the +/// line under the pointer, not the guide 2 px away. +#[test] +fn ctrl_release_inside_does_not_snap_the_line_to_a_guide() { + let mut app = app(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 252})).unwrap(); + two_rows(&mut app); + app.ui.extras.snap = true; + app.ui.tool = Tool::SingleRowMarquee; + tool_event(&mut app, ToolEvent::Down { x: 600.0, y: 250.5, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 600.0, y: 250.5 }, Modifiers::CTRL); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251)); +} + +/// A click on a floating piece drops it where it is and selects the line under the pointer, as +/// before a press on the piece dragged it: a click inside the selection selects a line with these +/// tools. It only dropped an unmoved piece (keeping its outline) and left a moved one floating. +#[test] +fn click_on_a_floating_piece_drops_it_and_selects_the_line() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.ui.extras.snap = false; + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + click(&mut app, Tool::SingleRowMarquee, [200.0, 150.5], Modifiers::NONE); + assert!(!floats(&app), "an unmoved piece is dropped"); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 151), "row 150, not the piece's outline"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("select.float", json!({"dx": 50, "dy": 20})).unwrap(); + click(&mut app, Tool::SingleColumnMarquee, [250.5, 170.0], Modifiers::NONE); + assert!(!floats(&app), "a moved piece is dropped where it is"); + assert_eq!(bounds(&app), Rect::new(250, 0, 251, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// With snapping on, a drag pressed just outside a selected row is a drag from outside: a new row +/// at the release. The snapped press landed on the row's edge, which counted as inside, so the drag +/// moved the selection instead. +#[test] +fn a_press_that_snaps_onto_the_selection_is_still_outside_it() { + let mut app = app(); + two_rows(&mut app); + app.ui.extras.snap = true; + drag(&mut app, Tool::SingleRowMarquee, [300.0, 148.5], [300.0, 100.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 100, 800, 101), "a new row at the release, not both rows moved up"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [198.5, 300.0], [100.5, 300.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(100, 0, 101, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// A Move-tool drag of a floating piece that snapping pulls back to its start, released exactly at +/// the press, is not a click: the piece keeps floating. The Move tool's click shortcut dropped it. +#[test] +fn a_snapped_back_move_tool_drag_keeps_the_piece_floating() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + app.ui.extras.snap = true; + app.ui.tool = Tool::Move; + let p = [200.0, 200.5]; + tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: p[0], y: p[1] + 2.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, Modifiers::NONE); + assert!(floats(&app), "the piece still floats"); + assert!(app.move_preview.is_none(), "no preview left from the drag"); +} diff --git a/crates/ui-egui/src/smart_objects_menu_tests.rs b/crates/ui-egui/src/smart_objects_menu_tests.rs new file mode 100644 index 0000000000..dd6d7947a6 --- /dev/null +++ b/crates/ui-egui/src/smart_objects_menu_tests.rs @@ -0,0 +1,103 @@ +//! Layer > Smart Objects menu rows, labels and order (#11401). + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::menus::{MenuItem, menu_items}; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) +} + +fn submenu(app: &PhotocraftApp) -> Vec { + menu_items(app).into_iter().filter(|i| i.path == ["Layer", "Smart Objects"]).collect() +} + +fn reveal() -> &'static str { + if cfg!(target_os = "macos") { + "Reveal in Finder" + } else if cfg!(windows) { + "Reveal in Explorer" + } else { + "Show in Folder" + } +} + +#[test] +fn the_rows_follow_the_reference_order() { + let labels: Vec = submenu(&app()).into_iter().map(|i| i.label).collect(); + let expected = [ + "Convert to Smart Object", + "New Smart Object via Copy", + "---", + reveal(), + "---", + "Update Modified Content", + "Update All Modified Content", + "---", + "Edit Contents", + "Relink to File…", + "Replace Contents…", + "Export Contents…", + "---", + "Embed Linked", + "Embed All Linked", + "Convert to Linked…", + "Convert to Layers", + "---", + "---", + "Rasterize", + ]; + assert_eq!(labels, expected, "{labels:?}"); +} + +#[test] +fn embed_rows_use_the_new_labels_and_the_ids_stay() { + let items = submenu(&app()); + let by_id = |id: &str| items.iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing")); + assert_eq!(by_id("layer.smartObjects.convertToEmbedded").label, "Embed Linked"); + assert_eq!(by_id("layer.smartObjects.embedAllLinked").label, "Embed All Linked"); + assert_eq!(by_id("layer.smartObjects.revealInFinder").label, reveal()); +} + +#[cfg(windows)] +#[test] +fn windows_says_reveal_in_explorer() { + assert_eq!(crate::menus::reveal_label(), "Reveal in Explorer"); +} + +#[test] +fn the_warp_rows_are_gone_from_the_submenu_but_the_commands_stay() { + let app = app(); + for id in ["layer.smartObjects.warp", "layer.smartObjects.perspectiveWarp", "layer.smartObjects.puppetWarp"] { + assert!(!submenu(&app).iter().any(|i| i.id == id), "{id}"); + assert!(photocraft_engine::cmd::command_specs().iter().any(|c| c.id == id), "{id} stays registered"); + } + let all = menu_items(&app); + for id in ["edit.puppetWarp", "edit.perspectiveWarp", "edit.transform.warp"] { + assert!(all.iter().any(|i| i.id == id), "{id} stays in the Edit menu"); + } +} + +#[test] +fn embed_all_linked_is_disabled_without_a_linked_smart_object_and_enabled_with_one() { + let mut app = app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("setup", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.sync_views(); + let enabled = |app: &PhotocraftApp, id: &str| submenu(app).iter().find(|i| i.id == id).unwrap().enabled; + assert!(!enabled(&app, "layer.smartObjects.embedAllLinked")); + let path = std::env::temp_dir().join(format!("pc-so-menu-{}.pcraft", std::process::id())); + app.run("layer.smartObjects.convertToLinked", json!({"path": path.to_str().unwrap()})).unwrap(); + app.sync_views(); + assert!(enabled(&app, "layer.smartObjects.embedAllLinked")); + assert!(enabled(&app, "layer.smartObjects.convertToEmbedded")); + std::fs::remove_file(&path).ok(); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ca0259a434..be7123f04a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -338,6 +338,9 @@ pub struct UiState { /// A live rectangle's corner radius widget being dragged (TOOL-214-12). #[serde(skip)] pub corner_radius: Option, + /// The bounding box of several selected shape layers, a handle being dragged. + #[serde(skip)] + pub shape_box: Option, /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, @@ -596,6 +599,7 @@ impl Default for UiState { stroke_editor: None, direct_selection: Default::default(), corner_radius: None, + shape_box: None, selected_path: None, hidden_work_path: None, panels: Panels::default(), @@ -739,10 +743,10 @@ mod tests { assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask)); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); - assert_eq!(Tool::DeleteAnchorPoint.key(), 'P'); + assert_eq!(Tool::DeleteAnchorPoint.key(), ''); assert_eq!(Tool::from_name("convertPoint"), Some(Tool::ConvertPoint)); assert_eq!(Tool::from_name("Convert Point Tool"), Some(Tool::ConvertPoint)); - assert_eq!(Tool::ConvertPoint.key(), 'P', "it shares the Pen's key"); + assert_eq!(Tool::ConvertPoint.key(), '', "P never reaches it"); assert!(!Tool::ConvertPoint.is_brushlike()); assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove)); assert_eq!(Tool::Remove.key(), 'J'); diff --git a/crates/ui-egui/src/state/tool.rs b/crates/ui-egui/src/state/tool.rs index cd96f525a1..8dda7ce231 100644 --- a/crates/ui-egui/src/state/tool.rs +++ b/crates/ui-egui/src/state/tool.rs @@ -99,8 +99,8 @@ impl Tool { Tool::Count, Tool::Brush, Tool::Pencil, - Tool::MixerBrush, Tool::ColorReplacement, + Tool::MixerBrush, Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser, @@ -113,8 +113,8 @@ impl Tool { Tool::Hand, Tool::RotateView, Tool::Zoom, - Tool::Remove, Tool::SpotHealing, + Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, @@ -284,7 +284,9 @@ impl Tool { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0', Tool::Dodge | Tool::Burn | Tool::Sponge => 'O', Tool::QuickSelection | Tool::ObjectSelection => 'W', - Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => 'P', + Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen => 'P', + // In the Pen flyout, but P never reaches them. + Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => '', Tool::PathSelection | Tool::DirectSelection => 'A', Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', } diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 960fb83a07..08259200fe 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -29,6 +29,15 @@ pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { crate::frame_tool::cancel(app); } app.ui.tool = tool; + ended_by_tool(app); +} + +/// Add New Artboard's placing mode ends with the Artboard tool (#11386): every place that sets +/// `ui.tool` calls this right after, so there is one rule. +pub(crate) fn ended_by_tool(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Artboard { + app.ui.artboard_tool.adding = false; + } } /// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's diff --git a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs new file mode 100644 index 0000000000..83c3ca33cc --- /dev/null +++ b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs @@ -0,0 +1,164 @@ +//! Shift+letter steps through a key's tool group in the toolbar flyout's order, wrapping, and the +//! anchor point tools have no letter so P never reaches them. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, toolbar_position}; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn shift_press(app: &mut PhotocraftApp, key: Key) { + let modifiers = Modifiers::SHIFT; + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +/// Every lettered tool group in toolbar order, with its letter. +fn groups() -> Vec<(char, Vec)> { + let mut keys: Vec = Tool::ALL.iter().map(|t| t.key()).filter(|k| *k != '\0').collect(); + keys.sort_unstable(); + keys.dedup(); + keys.into_iter() + .map(|k| { + let mut group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == k && toolbar_position(*t) != usize::MAX).collect(); + group.sort_by_key(|t| toolbar_position(*t)); + (k, group) + }) + .collect() +} + +fn key_for(letter: char) -> Key { + crate::spring_tools::key_of(letter).unwrap() +} + +fn sequence(letter: char, steps: usize) -> Vec { + let mut app = app(); + app.ui.tool = Tool::Hand; + (0..steps) + .map(|_| { + shift_press(&mut app, key_for(letter)); + app.ui.tool + }) + .collect() +} + +#[test] +fn from_every_tool_shift_letter_lands_on_the_next_tool_in_toolbar_order() { + for (letter, group) in groups() { + for (i, from) in group.iter().enumerate() { + let mut app = app(); + app.ui.tool = *from; + shift_press(&mut app, key_for(letter)); + let want = group[(i + 1) % group.len()]; + assert_eq!(app.ui.tool, want, "Shift+{letter} from {from:?}"); + } + } +} + +#[test] +fn shift_letter_wraps_from_the_last_tool_to_the_first() { + for (letter, group) in groups() { + let mut app = app(); + app.ui.tool = *group.last().unwrap(); + shift_press(&mut app, key_for(letter)); + assert_eq!(app.ui.tool, group[0], "Shift+{letter} wraps"); + } +} + +#[test] +fn the_first_shift_letter_from_another_tool_lands_on_the_groups_first_tool() { + for (letter, group) in groups() { + let mut app = app(); + app.ui.tool = Tool::Hand; + if group.contains(&Tool::Hand) { + continue; + } + shift_press(&mut app, key_for(letter)); + assert_eq!(app.ui.tool, group[0], "Shift+{letter} from the Hand tool"); + } +} + +#[test] +fn w_cycles_object_selection_quick_selection_magic_wand() { + assert_eq!(sequence('W', 4), vec![Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand, Tool::ObjectSelection]); +} + +#[test] +fn t_cycles_horizontal_vertical_vertical_mask_horizontal_mask() { + assert_eq!(sequence('T', 5), vec![Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask, Tool::Type]); +} + +#[test] +fn p_cycles_the_three_pens_and_never_reaches_an_anchor_point_tool() { + let seen = sequence('P', 7); + assert_eq!(&seen[..4], &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::Pen]); + for t in seen { + assert!(!matches!(t, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint), "{t:?}"); + } +} + +#[test] +fn the_anchor_point_tools_have_no_letter_but_stay_in_the_pen_flyout() { + for t in [Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint] { + assert_eq!(t.key(), '\0', "{t:?}"); + assert!(TOOL_SECTIONS.iter().flat_map(|s| s.iter()).any(|slot| slot.contains(&t)), "{t:?} stays in the toolbar"); + } +} + +#[test] +fn the_cycle_order_is_the_flattened_toolbar_order() { + let flat: Vec = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).flat_map(|slot| slot.iter().copied()).collect(); + for (i, t) in flat.iter().enumerate() { + assert_eq!(toolbar_position(*t), i, "{t:?}"); + } +} + +#[test] +fn with_the_shift_switch_preference_off_plain_w_keeps_the_old_order() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + app.ui.tool = Tool::MagicWand; + let mut seen = Vec::new(); + for _ in 0..3 { + let ctx = egui::Context::default(); + let modifiers = Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: Key::W, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + seen.push(app.ui.tool); + } + assert_eq!(seen, vec![Tool::QuickSelection, Tool::ObjectSelection, Tool::MagicWand]); +} + +#[test] +fn a_plain_w_from_outside_the_group_keeps_selecting_what_it_selected_before() { + let mut app = app(); + app.ui.tool = Tool::Move; + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: Key::W, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(app.ui.tool, Tool::MagicWand, "the first W tool in Tool::ALL, not the first in the flyout"); +} diff --git a/crates/ui-egui/src/toolbar_group_order_tests.rs b/crates/ui-egui/src/toolbar_group_order_tests.rs new file mode 100644 index 0000000000..46db63bcb3 --- /dev/null +++ b/crates/ui-egui/src/toolbar_group_order_tests.rs @@ -0,0 +1,145 @@ +//! The healing and brush slots list their tools in the reference order, the healing slot starts on +//! Spot Healing Brush, and J, B, Alt-click and Shift+letter all follow that order. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, next_slot_tool, toolbar}; +use crate::state::Tool; + +const HEALING: [Tool; 6] = [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; +const BRUSH: [Tool; 4] = [Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn slot_with(tool: Tool) -> &'static [Tool] { + TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&tool)).copied().unwrap() +} + +fn shift_sequence(key: Key, steps: usize) -> Vec { + let mut app = app(); + app.ui.tool = Tool::Hand; + (0..steps) + .map(|_| { + press(&mut app, key, Modifiers::SHIFT); + app.ui.tool + }) + .collect() +} + +#[test] +fn the_healing_slot_lists_spot_healing_first() { + assert_eq!(slot_with(Tool::Healing), &HEALING); +} + +#[test] +fn the_brush_slot_lists_color_replacement_before_mixer_brush() { + assert_eq!(slot_with(Tool::Brush), &BRUSH); +} + +#[test] +fn a_fresh_healing_slot_shows_spot_healing() { + assert_eq!(slot_with(Tool::Remove)[0], Tool::SpotHealing); +} + +#[test] +fn j_on_fresh_settings_selects_spot_healing() { + let mut app = app(); + app.ui.tool = Tool::Hand; + press(&mut app, Key::J, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::SpotHealing); +} + +#[test] +fn tool_all_orders_the_two_pairs_like_the_slots() { + let pos = |t: Tool| Tool::ALL.iter().position(|x| *x == t).unwrap(); + assert!(pos(Tool::SpotHealing) < pos(Tool::Remove)); + assert!(pos(Tool::ColorReplacement) < pos(Tool::MixerBrush)); +} + +#[test] +fn alt_click_steps_both_slots_in_the_new_order_and_wraps() { + for order in [&HEALING[..], &BRUSH[..]] { + for (i, from) in order.iter().enumerate() { + assert_eq!(next_slot_tool(slot_with(*from), *from), order[(i + 1) % order.len()], "from {from:?}"); + } + } +} + +#[test] +fn shift_j_follows_the_reference_sequence() { + let want: Vec = HEALING.iter().chain(&HEALING[..1]).copied().collect(); + assert_eq!(shift_sequence(Key::J, 7), want); +} + +#[test] +fn shift_b_follows_the_reference_sequence() { + let want: Vec = BRUSH.iter().chain(&BRUSH[..1]).copied().collect(); + assert_eq!(shift_sequence(Key::B, 5), want); +} + +fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) { + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(time), + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 1800.0))), + ..Default::default() + }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); +} + +/// Alt-click on the slot button, through the pointer, steps the reordered healing and brush slots. +#[test] +fn pointer_alt_click_steps_the_healing_and_brush_slots_in_the_new_order() { + let mut app = app(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if crate::theme::Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let alt = Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let mut t = 1.0; + for order in [&HEALING[..], &BRUSH[..]] { + let index = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).position(|slot| slot.contains(&order[0])).unwrap(); + let at = buttons[index].center(); + app.ui.tool = order[0]; + for want in order.iter().cycle().skip(1).take(order.len()) { + frame(&mut app, &ctx, t, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, t + 0.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, *want, "alt-click at {t}"); + t += 0.5; + } + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index e53d2ecb0b..537ecd342e 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -34,6 +34,7 @@ pub mod perspective_crop_ui; pub mod perspective_ui; pub mod puppet_ui; pub mod retouch_ui; +pub mod shape_bounds_box; pub(super) mod shape_gradient_ui; pub(super) mod shape_options; pub(super) mod shape_paint_ui; diff --git a/crates/ui-egui/src/tools/anchor_tools/tests.rs b/crates/ui-egui/src/tools/anchor_tools/tests.rs index 02d0f45dc8..d7cd7256a4 100644 --- a/crates/ui-egui/src/tools/anchor_tools/tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/tests.rs @@ -44,13 +44,9 @@ fn click(app: &mut PhotocraftApp, at: [f64; 2], mods: Modifiers) { } #[test] -fn both_tools_join_the_pen_group() { +fn the_anchor_tools_have_no_letter() { let group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == 'P').collect(); - assert_eq!( - group, - vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint], - "P cycles Pen → Freeform → Curvature → Add → Delete → Convert Point" - ); + assert_eq!(group, vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen], "P cycles Pen → Freeform → Curvature and never reaches the anchor point tools"); let mut app = app(Tool::Brush); app.ui.tool = Tool::from_name("addAnchorPoint").unwrap(); assert_eq!(app.ui.tool, Tool::AddAnchorPoint); diff --git a/crates/ui-egui/src/tools/artboard_tool.rs b/crates/ui-egui/src/tools/artboard_tool.rs index 81dd67bd02..80e8d4779a 100644 --- a/crates/ui-egui/src/tools/artboard_tool.rs +++ b/crates/ui-egui/src/tools/artboard_tool.rs @@ -3,9 +3,10 @@ //! selected board's edge and corner handles to resize it (⇧ on a corner keeps its proportions). //! The "+" buttons beside the selected board's sides add a board of the same size there (⌥: a copy //! with its contents). The options bar sets the selected board's size (preset, W/H, -//! portrait/landscape) and adds a board of the same size beside the last one. Every change is one -//! engine command (`layer.new.artboard`, `layer.artboard.set`, `layer.artboard.addBeside`), so each -//! gesture is one undo step; this module only turns pointer gestures into them. +//! portrait/landscape) and switches on Add New Artboard's placing mode (a click puts a board of the +//! bar's size at the click). Every change is one engine command (`layer.new.artboard`, +//! `layer.artboard.set`, `layer.artboard.addBeside`), so each gesture is one undo step; this module +//! only turns pointer gestures into them. use egui::{CursorIcon, Pos2, Rect, Stroke, vec2}; use photocraft_doc::{Document, Layer, LayerId}; @@ -18,6 +19,8 @@ use crate::crop_ui::{Hit, hit}; use crate::state::Tool; use crate::theme::Tokens; +mod add_mode; + /// How far from an edge (screen points) a press still grabs its handle. const HANDLE_PX: f64 = 6.0; /// A press that moves less than this (screen points) is a click, not a drawn board. @@ -34,6 +37,8 @@ const SIDES: [(&str, [f64; 2]); 4] = [("top", [0.0, -1.0]), ("right", [1.0, 0.0] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ArtboardToolUi { + /// Add New Artboard's placing mode: a click on empty canvas puts a board there (#11386). + pub adding: bool, #[serde(skip)] pub drag: Option, } @@ -184,7 +189,11 @@ fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) { pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, // so it drags a guide or the layer (#6288). A drag already under way keeps its events. - if app.ui.tool != Tool::Artboard || (app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard) { + if app.ui.tool != Tool::Artboard { + add_mode::leave(app); + return false; + } + if app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard { return false; } let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; @@ -222,10 +231,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> let result = match d { ArtboardDrag::New { start, cur } => { let screen = (cur[0] - start[0]).hypot(cur[1] - start[1]) * f64::from(app.point_zoom()); - if screen < CLICK_PX || r[2] <= r[0] || r[3] <= r[1] { + if screen < CLICK_PX { + // A click: with Add New Artboard's mode on, it places a board. + if let Some(placed) = add_mode::click(app, start) { + report(app, placed); + } return true; } - app.run("layer.new.artboard", json!({"rect": xywh(r)})) + if r[2] <= r[0] || r[3] <= r[1] { + return true; + } + app.run("layer.new.artboard", json!({"rect": xywh(r), "takeBackground": true})) } ArtboardDrag::Move { id, rect, .. } if r != rect => app.run("layer.artboard.set", json!({"layer": id.0, "x": r[0], "y": r[1]})), // Resizing leaves the contents where they are, as in Photoshop. @@ -292,6 +308,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform /// Landscape, and Add New Artboard. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { if tool != Tool::Artboard { + add_mode::leave(app); return false; } let t = Tokens::get(ui.ctx()); @@ -328,12 +345,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } crate::widgets::vline(ui, 22.0); let has_doc = app.session.active().is_some(); - if ui.add_enabled_ui(has_doc, |ui| crate::widgets::secondary_button(ui, tl!("Add New Artboard"), 0.0)).inner.clicked() { - // A board the size of the selected one, right of the rightmost board (the canvas size - // without a selected board). - let p = board.map_or(json!({}), |(_, r)| json!({"width": r[2] - r[0], "height": r[3] - r[1]})); - let r = app.run("layer.new.artboard", p); - report(app, r); + // Drawn as the primary button while the placing mode is on. + let adding = add_mode::active(app); + let button = ui + .add_enabled_ui(has_doc, |ui| { + if adding { + crate::widgets::primary_button(ui, tl!("Add New Artboard"), 0.0) + } else { + crate::widgets::secondary_button(ui, tl!("Add New Artboard"), 0.0) + } + }) + .inner; + if button.clicked() { + add_mode::toggle(app); } true } diff --git a/crates/ui-egui/src/tools/artboard_tool/add_mode.rs b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs new file mode 100644 index 0000000000..c46d6d737d --- /dev/null +++ b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs @@ -0,0 +1,46 @@ +//! The options bar's Add New Artboard placing mode (#11386): the button switches the mode on, and +//! while it is on a click on empty canvas puts a board of the bar's size with its top-left corner +//! at the click. The engine's `layer.new.artboard` does the work; this only turns the click into +//! its `rect`. + +use serde_json::{Value, json}; + +use super::active_board; +use crate::PhotocraftApp; +use crate::state::Tool; + +/// True while the Artboard tool is current and its placing mode is on. +pub(super) fn active(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Artboard && app.ui.artboard_tool.adding +} + +/// The Add New Artboard button: turns the mode on, or off when it is already on. +pub(super) fn toggle(app: &mut PhotocraftApp) { + app.ui.artboard_tool.adding = !app.ui.artboard_tool.adding; +} + +/// Another tool is current: the mode ends. +pub(super) fn leave(app: &mut PhotocraftApp) { + app.ui.artboard_tool.adding = false; +} + +/// The size the options bar shows: the selected board's, else the canvas size (the engine's +/// fallback for a board with no size). +fn bar_size(app: &PhotocraftApp) -> Option<[i64; 2]> { + if let Some((_, r)) = active_board(app) { + return Some([r[2].saturating_sub(r[0]), r[3].saturating_sub(r[1])]); + } + let size = app.session.active()?.doc.size; + Some([i64::from(size.width), i64::from(size.height)]) +} + +/// A click at document point `p` on empty canvas: one `layer.new.artboard` with the bar's size and +/// its top-left corner at the click, rounded to whole pixels. None when the mode is off. +pub(super) fn click(app: &mut PhotocraftApp, p: [f64; 2]) -> Option> { + if !active(app) { + return None; + } + let [w, h] = bar_size(app)?; + let rect = [p[0].round() as i64, p[1].round() as i64, w, h]; + Some(app.run("layer.new.artboard", json!({"rect": rect}))) +} diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 4401e9b15d..c0afceb0d1 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -458,6 +458,33 @@ fn commit_is_the_same_in_both_modes_and_the_view_stays_upright() { assert!(out[0].1 == out[1].1, "same pixels"); } +/// #9202: switching documents drops a turned pending crop and turns its own document's view +/// upright, as Esc would, not only the view of the document switched to. +#[test] +fn switching_documents_turns_the_dropped_crops_view_upright() { + // The crop on the first document, then on the second. + for (on, other) in [(0, 1), (1, 0)] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(on)); + app.sync_views(); + let v = &mut app.ui.views[on]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "crop on {on}: the view turned with the frame"); + assert!(app.session.set_active(other)); + app.sync_views(); + assert_eq!(app.ui.crop_rect, None, "crop on {on}: the turned crop is dropped"); + app.ui.tool = Tool::Brush; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.session.set_active(on)); + app.sync_views(); + assert_eq!(view_rotation(&app), 0.0, "crop on {on}: its document isn't left turned"); + } +} + #[test] fn picking_the_crop_tool_turns_a_rotated_view_upright() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index ef5416d8a6..490be07fa5 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -499,6 +499,29 @@ pub fn active(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_some() } +/// A drag that sizes or moves the frame over the image, which scrolls the view when held past the +/// canvas edge (#4183). Not the turns, nor the default mode's image moves and live-centred resizes: +/// those drive the camera themselves. A new frame's drag that hasn't left the size of a click (a +/// long press that slipped past the edge) doesn't either: scrolling would draw a frame under the +/// still pointer. +pub fn autoscrolls(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop + && match app.crop.drag { + Some(CropDrag::Draw { anchor, cur, .. }) => !is_click(app, anchor, cur), + Some(CropDrag::Move { .. } | CropDrag::Resize { view: None, .. }) => true, + _ => false, + } +} + +/// A new-frame drag shorter than this (screen points) on both axes is still a click. +const CLICK_PX: f64 = 3.0; + +/// Document points `a` and `b` lie within [`CLICK_PX`] screen points of each other on both axes. +fn is_click(app: &PhotocraftApp, a: [f64; 2], b: [f64; 2]) -> bool { + let click = CLICK_PX / (app.point_zoom() as f64).max(0.01); + (b[0] - a[0]).abs() <= click && (b[1] - a[1]).abs() <= click +} + /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the /// Crop tool still picked, [`ensure_frame`] then makes the active document a new default frame. pub fn cancel(app: &mut PhotocraftApp) { @@ -653,6 +676,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Intercept Undo/Redo/Toggle Last State while the Crop tool owns box history, for every caller /// (menus, shortcuts, the control channel, MCP) — as `transform_tool::intercept` does. pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { + if holds_history(app, id) { + return Some(Err("Commit or cancel the crop first".into())); + } if app.ui.tool != Tool::Crop || (app.crop.undo.is_empty() && app.crop.redo.is_empty()) { return None; } @@ -664,6 +690,13 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option bool { + pending(app) && matches!(id, "history.selectState" | "history.selectSnapshot" | "history.stepBackward" | "history.stepForward" | "history.deleteState") +} + /// Undo (or redo) one box step. fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { let s = if redo { app.crop.redo.pop() } else { app.crop.undo.pop() }; @@ -750,6 +783,16 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { let pending = app.ui.crop_rect.is_some() || app.crop.drag.is_some(); // A frame made before any document was claimed (control channel, tests) is the active one's. if pending && app.crop.doc.is_some() && app.crop.doc != active { + // Default mode turned the frame's own view, which `cancel` doesn't reach: it turns the + // active one upright, and `ui.views` is still in the order of `view_docs` here (#9202). + if !crate::crop_mode::classic(app) { + let doc = app.crop.doc; + let synced = app.view_docs.iter().position(|d| Some(*d) == doc); + let i = synced.or_else(|| app.session.documents().iter().position(|st| Some(st.doc.id) == doc)); + if let Some(v) = i.and_then(|i| app.ui.views.get_mut(i)) { + v.rotation = 0.0; + } + } cancel(app); } app.crop.doc = active; @@ -952,7 +995,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// Pointer input for the Crop tool. Returns true when the event was its. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if app.ui.tool != Tool::Crop { - app.crop.drag = None; + end_drag(app); return false; } let p = match ev { @@ -1060,7 +1103,9 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { let ok = app.ui.crop_rect.is_some_and(size_ok); // A click outside the untouched default frame (no turn) leaves it the default. let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) - && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); + && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)) + // A press that never moved draws nothing: the frame shown is still the previous one. + && !matches!(drag, CropDrag::Draw { anchor, cur, .. } if anchor == cur); if ok && changed { app.crop.default_frame = false; } @@ -1072,6 +1117,25 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { push_press_step(app); } +/// Another tool was picked while a drag on the frame is held (a tool key, #9223): the gesture ends +/// where the pointer last moved it, as a release there would, so a box drawn over the untouched +/// default frame counts as drawn and leaving the tool commits it. +pub fn end_drag(app: &mut PhotocraftApp) { + let Some(drag) = app.crop.drag.take() else { return }; + let tool = std::mem::replace(&mut app.ui.tool, Tool::Crop); + release(app, drag); + crate::crop_mode::sync_frame(app); + app.ui.tool = tool; +} + +/// [`end_drag`] once another tool is current, except while the pen's eraser end stands in for the +/// Crop tool (`stylus`): the gesture goes on when the tip comes back. +pub fn end_drag_on_leave(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Crop && app.stylus.tool_before_eraser != Some(Tool::Crop) { + end_drag(app); + } +} + /// On release: the frame state at the press becomes a box Undo step when the gesture changed it. fn push_press_step(app: &mut PhotocraftApp) { if let Some(before) = app.crop.press_step.take() diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 0d5c356b40..93e2de2556 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -496,6 +496,27 @@ fn moving_the_frame_snaps_its_edges() { assert_eq!(app.ui.crop_rect, Some([0.0, 10.0, 40.0, 40.0]), "left edge snaps to the document edge"); } +/// A click on the frame (released where it was pressed) changes nothing, as a Move-tool click +/// doesn't move the layer: snapping the release would pull an edge within reach onto the +/// document's, so a double-click to commit cropped another rect. +#[test] +fn a_click_on_the_frame_or_a_handle_never_snaps_it() { + let frame = [5.0, 10.0, 105.0, 60.0]; + for classic in [true, false] { + // Inside (Classic: moves the frame; default: moves the image), the left edge's handle, the top-left corner's. + for p in [[50.0, 30.0], [5.0, 35.0], [5.0, 10.0]] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.crop_rect = Some(frame); + app.crop.default_frame = false; + drag(&mut app, &[p], NONE); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}, click at {p:?}"); + assert!(!crate::menus::is_enabled(&app, "edit.undo"), "classic {classic}, click at {p:?}: no box step"); + } + } +} + /// #3145: Edit › Undo while a crop is pending steps the *box*, not the document (Photoshop keeps /// Undo live during a crop), so a moved frame comes back. #[test] @@ -532,6 +553,41 @@ fn box_undo_and_redo_grey_out_when_their_stack_is_empty() { assert!(crate::menus::is_enabled(&app, "edit.redo")); } +/// A History jump under a drawn frame would resize the document while the frame keeps its +/// coordinates, so Enter cropped the old ones (#9285): the panel's rows, snapshots and menu +/// wait for the crop, as they do for Free Transform. +#[test] +fn history_jumps_wait_for_a_pending_crop() { + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + let mut app = app(SampleType::U8); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("image.canvasSize", json!({"width": 300, "height": 200})).unwrap(); + let snapshot = app.session.active().unwrap().snapshots[0].id; + app.ui.crop_rect = Some([220.0, 120.0, 290.0, 190.0]); + app.crop.default_frame = false; + for (cmd, p) in [ + ("history.selectState", json!({"state": 0})), + ("history.selectSnapshot", json!({"snapshot": snapshot})), + ("history.stepBackward", json!({})), + ("history.deleteState", json!({})), + ] { + assert!(app.run(cmd, p).is_err(), "{cmd} waits for the crop"); + assert_eq!(size(&app), Some((300, 200)), "{cmd} left the document as it was"); + } + assert_eq!(app.ui.crop_rect, Some([220.0, 120.0, 290.0, 190.0]), "the frame stays"); + // Without a drawn frame (the tool's own frame around the canvas) History works as before. + app.crop.default_frame = true; + app.run("history.selectState", json!({"state": 0})).unwrap(); + assert_eq!(size(&app), Some((200, 100))); + // Step Forward has a state to redo now; a drawn frame holds it back too. + app.crop.default_frame = false; + assert!(app.run("history.stepForward", json!({})).is_err(), "history.stepForward waits for the crop"); + assert_eq!(size(&app), Some((200, 100))); + app.crop.default_frame = true; + app.run("history.stepForward", json!({})).unwrap(); + assert_eq!(size(&app), Some((300, 200))); +} + #[test] fn handles_resize_with_shift_and_alt() { let mut app = app(SampleType::U8); @@ -665,6 +721,226 @@ fn space_on_the_canvas_moves_the_frame_not_the_view() { assert!(st.crop.drag.is_none()); } +/// Real egui input: a handle dragged past the canvas edge and held there scrolls the view, as a +/// marquee drag does (#4183), so the frame can reach the part of the image that was off screen. +#[test] +fn a_handle_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The right edge's middle handle: document (100, 25). + let p0 = h.state().ui.views[0].center; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: p0, flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 25.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Resize { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[2] > 175.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(out, false)); + h.step(); +} + +/// A press that started off the canvas (a panel slider beside it) doesn't scroll the view, +/// even with a crop drag still recorded: only a drag on the canvas auto-scrolls. +#[test] +fn a_press_beside_the_canvas_does_not_scroll_it_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + // Left over from a drag a tool switch didn't end. + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The canvas takes the left 400 points; the rest stands in for a panel. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p = pos2(500.0, 100.0); + h.event(Event::PointerMoved(p)); + h.event(button(p, true)); + h.step(); + h.event(Event::PointerMoved(pos2(550.0, 100.0))); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "the view stayed put"); + h.event(button(pos2(550.0, 100.0), false)); + h.step(); +} + +/// The 200x100 test document at 400% (document x 25..175 shows in a 600-point canvas). +fn zoomed_view(height: u32) -> crate::state::View { + crate::state::View { + zoom: 4.0, + center: [100.0, height as f32 / 2.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, height], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } +} + +/// A press held just inside the canvas edge (past egui's click time, so egui starts a drag) that +/// slips a point past it is still a click: it draws no frame and doesn't scroll the view, which +/// would grow a frame under the still pointer. A new frame dragged past the edge does scroll. +#[test] +fn a_held_click_at_the_canvas_edge_draws_no_frame_and_does_not_scroll() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![zoomed_view(100)]; + a.ui.extras.rulers = false; + // The canvas starts 100 points from the window's left, so its left edge (no scrollbar there) + // has room past it. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let frame = h.state().ui.crop_rect; + assert!(h.state().crop.default_frame, "{frame:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert!(matches!(h.state().crop.drag, Some(CropDrag::Draw { .. })), "egui started a drag: {:?}", h.state().crop.drag); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.ui.crop_rect, frame, "the click drew no frame"); + assert!(st.crop.default_frame); + // A new frame dragged from inside the canvas past its edge does scroll. + let start = pos2(300.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(240.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Draw { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the frame drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[0] < 25.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); +} + +/// A scrollbar drag is the scrollbar's, even with a crop drag still recorded (left over from a +/// drag a tool switch didn't end): dragged out past the canvas edge, it doesn't auto-scroll the +/// view as a crop drag would. +#[test] +fn a_scrollbar_drag_past_the_canvas_edge_does_not_scroll_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let doc = Document::with_background("crop", Size::new(200, 200), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::Crop; + a.ui.tool_options.crop_shield.classic_mode = true; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x200 at 400% in a 600x400 canvas: both scrollbars show. + a.ui.views = vec![zoomed_view(200)]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The window is taller than the canvas, so the pointer can leave it below the bottom bar. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 500.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().ui.view.shows_scrollbars()); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The horizontal bar's thumb (the view is centred, so is the thumb), dragged straight down + // out of the canvas: the bar keeps x, and nothing else may move the view. + let thumb = pos2(294.0, 400.0 - crate::canvas::scrollbars::THICKNESS / 2.0); + h.event(Event::PointerMoved(thumb)); + h.event(button(thumb, true)); + h.step(); + for p in [pos2(294.0, 420.0), pos2(294.0, 480.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 100.0], "the view stayed put"); + h.event(button(pos2(294.0, 480.0), false)); + h.step(); +} + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and @@ -984,6 +1260,126 @@ fn the_stylus_eraser_and_a_gesture_in_progress_keep_the_frame_pending() { assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); } +/// A tool key pressed while a drag on the frame is held (⇧C to Perspective Crop): the next +/// tool's events end the gesture, even a tool that takes every event before the Crop tool's +/// handler, and leaving then commits the frame as it does for any other tool (#3368). +#[test] +fn a_tool_switch_during_a_drag_ends_it_and_commits_the_frame() { + for tool in [Tool::PerspectiveCrop, Tool::Ruler, Tool::Slice, Tool::Frame, Tool::Artboard, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 20.0], [120.0, 80.0]], NONE); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 50.0, pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?}"); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 60), "{tool:?}"); + } +} + +/// The same mid-drag tool switch on the untouched default frame: a box drawn over it, or the +/// frame resized by a handle, is an edit and commits on leave; a box too small to keep goes back +/// to the default frame, which leaving drops (#9223). +#[test] +fn a_tool_switch_during_a_drag_on_the_default_frame_keeps_the_edit() { + let cases: [(&[[f64; 2]], Size); 3] = [ + (&[[20.0, 20.0], [120.0, 80.0]], Size::new(100, 60)), + (&[[200.0, 100.0], [150.0, 80.0]], Size::new(150, 80)), + (&[[20.0, 20.0], [20.0, 20.0]], Size::new(200, 100)), + ]; + for (path, size) in cases { + for tool in [Tool::PerspectiveCrop, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame); + tool_event(&mut app, ToolEvent::Down { x: path[0][0], y: path[0][1], pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: path[1][0], y: path[1][1], pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?} {path:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?} {path:?}"); + assert_eq!(app.session.active().unwrap().doc.size, size, "{tool:?} {path:?}"); + } + } +} + +/// Real egui input: a tool picked while a box is being drawn over the untouched default frame +/// (a tool key mid-drag) commits that box on leaving, whichever tool it is; the canvas ends the +/// gesture before the frame sees the switch, also for the Hand, whose events never reach +/// `tool_event` (#9223). +#[test] +fn a_tool_switch_during_a_real_drag_commits_the_drawn_box() { + use egui::{Event, PointerButton, pos2}; + for tool in [Tool::Brush, Tool::PerspectiveCrop, Tool::Hand] { + let mut a = app(SampleType::U8); + a.sync_views(); + let v = &mut a.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.doc_size, v.rotation) = (2.0, [100.0, 50.0], false, [200, 100], 0.0); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().crop.default_frame, "{tool:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p0 = pos2(200.0, 150.0); + h.event(Event::PointerMoved(p0)); + h.event(button(p0, true)); + h.step(); + for p in [pos2(220.0, 170.0), pos2(260.0, 190.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + assert!(h.state().crop.drag.is_some(), "{tool:?}"); + h.state_mut().ui.tool = tool; + h.event(Event::PointerMoved(pos2(270.0, 195.0))); + h.step(); + h.event(button(pos2(270.0, 195.0), false)); + h.run_steps(2); + let st = h.state(); + assert!(st.crop.drag.is_none(), "{tool:?}"); + assert_eq!(st.ui.crop_rect, None, "{tool:?}"); + assert_eq!(st.session.active().unwrap().doc.size, Size::new(30, 20), "{tool:?}: the drawn 60x40 screen px at 200%"); + } +} + +/// A press inside the untouched default frame that never moves draws nothing, so the frame +/// stays the default one and leaving drops it: no crop to the selection's bounds, released or +/// cut short by a tool switch (#9223). +#[test] +fn a_press_that_never_moves_leaves_the_default_frame_untouched() { + for switch in [false, true] { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 40.0, 60.0])); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, NONE); + if switch { + app.ui.tool = Tool::Brush; + end_drag_on_leave(&mut app); + } else { + tool_event(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }, NONE); + assert!(app.crop.default_frame, "a click is no edit"); + app.ui.tool = Tool::Brush; + } + ensure_frame(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "switch: {switch}"); + assert_eq!(app.ui.crop_rect, None, "switch: {switch}"); + } +} + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index 4c96702694..9c91eff38c 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -1,12 +1,14 @@ //! Move-tool modifier keys, as in Photoshop (#90): //! -//! - ⇧ while dragging locks the move to the dominant axis (horizontal or vertical). The axis is -//! re-evaluated as the drag goes on, so moving far enough the other way switches it; a small -//! hysteresis keeps it from flickering near the diagonal. Free Transform uses the same rule with -//! eight directions ([`constrain`]). +//! - ⇧ while dragging locks the move to the nearest of the eight directions (the axes and the 45° +//! diagonals), as the reference application does when a layer is dragged (#11382): +100,+30 +//! moves +100,0 and +80,+80 moves +80,+80. The direction is re-evaluated as the drag goes on, +//! so moving far enough another way switches it; a small hysteresis keeps it from flickering +//! near the switch-over angle. Free Transform uses the same rule ([`constrain`]). //! - ⌥ held when the drag starts duplicates the selected layer(s) (groups included) once the //! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one -//! history step ("Duplicate + Move"). ⇧⌥ combines both. +//! history step ("Move", as in Photoshop; an ⌥-arrow nudge keeps "Duplicate + Move"). ⇧⌥ +//! combines both. //! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free //! Transform is active they nudge the box instead. A run of nudges is one History state //! (`nudge_bundle`, #2259). @@ -23,8 +25,10 @@ use crate::PhotocraftApp; use crate::canvas::ToolEvent; use crate::state::Tool; -/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool). -pub const MOVE_DIRECTIONS: u32 = 4; +/// Directions ⇧ locks a Move-tool drag of a layer to: the axes and the 45° diagonals. The +/// reference application moves +80,+80 for a ⇧-drag of +80,+80, so an axis-only lock (four +/// directions) does not match it (#11382). +pub const MOVE_DIRECTIONS: u32 = 8; /// Directions ⇧ locks a Free Transform body drag, or a Move-tool drag of selected pixels, to: the /// axes and the 45° diagonals. pub const TRANSFORM_DIRECTIONS: u32 = 8; @@ -34,7 +38,7 @@ const HYSTERESIS_DEG: f64 = 8.0; /// ⌥-drag duplicates only once the pointer has moved this far (screen points), so an ⌥-click /// without a drag doesn't make a copy (Photoshop). const DUPLICATE_THRESHOLD: f64 = 2.0; -/// History label of an ⌥-drag / ⌥-nudge. +/// History label of an ⌥-nudge. An ⌥-drag of a layer is a plain "Move", as in Photoshop (#11383). pub const DUPLICATE_MOVE_LABEL: &str = "Duplicate + Move"; /// Locks the drag offset `d` to the nearest of `n` evenly spaced directions (0° = right). `prev` @@ -105,8 +109,8 @@ fn duplicate(app: &mut PhotocraftApp) -> Option { } } -/// Merges every history step after `from` into one "Duplicate + Move" step. -fn fold_history(app: &mut PhotocraftApp, from: usize) { +/// Merges every history step after `from` into one step called `label`. +fn fold_history(app: &mut PhotocraftApp, from: usize, label: &str) { let Some(st) = app.session.active_mut() else { return }; while st.history.past_len() > from + 1 { if !st.history.purge_last() { @@ -114,7 +118,7 @@ fn fold_history(app: &mut PhotocraftApp, from: usize) { } } if st.history.past_len() == from + 1 { - st.history.set_current_label(DUPLICATE_MOVE_LABEL); + st.history.set_current_label(label); } } @@ -185,7 +189,7 @@ pub(crate) fn duplicated(app: &PhotocraftApp) -> bool { pub fn finish(app: &mut PhotocraftApp) { let st = std::mem::take(&mut app.move_mods); if let Some(from) = st.dup_from { - fold_history(app, from); + fold_history(app, from, tl!("Move")); } } @@ -269,6 +273,7 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { return; } let from = if duplicate_first { duplicate(app) } else { None }; + let revision = app.session.active().map(|st| st.revision); let moved = if duplicate_first { // A copy is a state of its own, folded with its move below. app.nudge_bundle.reset(); @@ -276,11 +281,20 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { } else { crate::nudge_bundle::run(app, "layers", "layer.translate", json!({"dx": dx, "dy": dy})) }; - if let Err(e) = moved { - app.ui.status = e; + match moved { + // The reference names an arrow-key move "Nudge"; a bundled run is one step with that name. + // Only a move that changed the document: a zero offset records nothing, and the state + // on top is then another edit's. + Ok(_) if from.is_none() => { + if let Some(st) = app.session.active_mut().filter(|st| Some(st.revision) != revision) { + st.history.set_current_label(tl!("Nudge")); + } + } + Ok(_) => {} + Err(e) => app.ui.status = e, } if let Some(from) = from { - fold_history(app, from); + fold_history(app, from, DUPLICATE_MOVE_LABEL); } } diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index e42e24875e..1e4c45b784 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -90,7 +90,7 @@ fn alt_drag_duplicates_and_moves_the_copy_in_one_step() { assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(18, 13, 34, 29)); let st = app.session.active().unwrap(); assert_eq!(st.history.past_len(), h0 + 1, "one undo step"); - assert_eq!(st.history.undo_label(), Some(DUPLICATE_MOVE_LABEL)); + assert_eq!(st.history.undo_label(), Some("Move")); assert!(app.session.undo()); assert_eq!(app.session.active().unwrap().doc.layers.len(), n0); assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24)); @@ -115,7 +115,7 @@ fn alt_drag_keeps_the_later_states_in_non_linear_history() { assert!(app.session.undo()); let mut expected = app.session.active().unwrap().history.entries(); expected.extend(app.session.active().unwrap().history.redo_labels().map(str::to_string)); - expected.push(DUPLICATE_MOVE_LABEL.into()); + expected.push("Move".into()); drag(&mut app, &[[16.0, 16.0], [20.0, 16.0], [26.0, 21.0]], egui::Modifiers::ALT); assert_eq!(app.session.active().unwrap().history.entries(), expected, "the later state stays"); } @@ -328,7 +328,7 @@ fn a_run_of_arrow_nudges_is_one_history_step() { assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34)); assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); - assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge")); assert!(app.session.undo()); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); assert!(app.session.redo()); @@ -434,7 +434,7 @@ fn alt_nudges_do_not_join_a_run() { press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT); press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); assert_eq!(history(&app), h0 + 3); - assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move"), "the run after the copy is a state of its own"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge"), "the run after the copy is a state of its own"); } /// #2259: nudges of the selection outline bundle the same way. diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index 2a696bd5ed..ed60c90d41 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -53,6 +53,24 @@ pub fn pending(app: &PhotocraftApp) -> bool { app.ui.perspective_crop.is_some() || !app.pcrop.clicks.is_empty() || app.pcrop.drag.is_some() } +/// A box, corner or quad drag in progress: held past the canvas edge, it scrolls the view (#4183). +/// A press that hasn't moved further than a click doesn't: scrolling would turn a corner-placing +/// click near the edge into a box. +pub fn dragging(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::PerspectiveCrop + && match app.pcrop.drag { + Some(Drag::New { start, cur }) => !is_click(app, start, cur), + Some(_) => true, + None => false, + } +} + +/// A new-box press released within [`CLICK_PX`] screen points of where it began places a corner. +fn is_click(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> bool { + let click = crate::distort_ui::tolerance(app) * CLICK_PX / 8.0; + (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click +} + pub fn cancel(app: &mut PhotocraftApp) { app.ui.perspective_crop = None; app.pcrop.drag = None; @@ -121,8 +139,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { ToolEvent::Up { .. } => { drag_to(app, p); if let Some(Drag::New { start, cur }) = app.pcrop.drag { - let click = tol * CLICK_PX / 8.0; - if (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click { + if is_click(app, start, cur) { app.pcrop.clicks.push(start); if let [a, b, c, d] = app.pcrop.clicks[..] { app.ui.perspective_crop = Some(ordered([a, b, c, d])); @@ -327,6 +344,202 @@ mod tests { assert_eq!(app.ui.perspective_crop, None); } + /// Real egui input: a corner dragged past the canvas edge and held there scrolls the view, as a + /// marquee drag does (#4183), so the corner can reach the part of the image that was off screen. + #[test] + fn a_corner_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // The top-right corner: document (100, 10). + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::Corner { index: 1, .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.perspective_crop.is_some_and(|q| q[1][0] > 175.0), "the corner reached past what showed: {:?}", st.ui.perspective_crop); + h.event(button(out, false)); + h.step(); + } + + /// ⌘Space (Ctrl+Space off the Mac) pressed during a corner drag held past the canvas edge + /// selects the temporary Zoom, which takes the pointer from the drag: the view no longer + /// scrolls for the corner the Zoom froze. + #[test] + fn a_temporary_zoom_during_a_corner_drag_stops_the_scrolling() { + use egui::{Event, Key, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(800, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 800x100 at 400% in a 600x400 canvas: document x 25..175 shows, with room to scroll right. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [800, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true, Modifiers::NONE)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + // Scrolling while the crop tool has the pointer. + let before = h.state().ui.views[0].center; + h.run_steps(2); + assert!(h.state().ui.views[0].center[0] > before[0], "the corner drag scrolls: {:?} -> {:?}", before, h.state().ui.views[0].center); + let zoom = Modifiers::COMMAND; + let zoom = if cfg!(target_os = "macos") { Modifiers { mac_cmd: true, ..zoom } } else { Modifiers { ctrl: true, ..zoom } }; + h.event(Event::ModifiersChanged(zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: zoom }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn)); + assert!(h.state().pcrop.drag.is_some(), "the corner drag is still recorded"); + let before = h.state().ui.views[0].center; + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, before, "the temporary Zoom's press doesn't scroll for the corner"); + h.event(button(out, false, zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: zoom }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + + /// A corner-placing click pressed just inside the canvas edge that slips a point past it doesn't + /// scroll the view (scrolling would turn it into a box); a new box dragged past the edge does. + #[test] + fn a_click_at_the_canvas_edge_places_a_corner_without_scrolling() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas 100 points from the window's left: document x 25..175 + // shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Pressed a point inside the left edge (the scrollbars sit right and bottom), slipping two + // points past it: still a click. + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + // Held past egui's click time, the press becomes a drag that hasn't left the click size. + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.pcrop.clicks.len(), 1, "the click placed a corner: {:?}", st.pcrop.clicks); + assert_eq!(st.ui.perspective_crop, None); + // A new box dragged from inside the canvas past its edge does scroll. + let start = pos2(200.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(140.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::New { .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the box drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index f2293d6bc7..4a084bc80a 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -762,16 +762,16 @@ fn create_a_new_layer_sends_new_layer_to_the_command() { #[test] fn red_eye_is_in_the_j_flyout() { - let j = [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; + let j = [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; assert!(j.contains(&Tool::RedEye)); assert!(j.iter().all(|t| t.key() == 'J')); } #[test] -fn j_cycles_from_remove() { +fn j_cycles_from_spot_healing() { // J from another group picks the first J tool, and repeated J goes on in this order. let group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == 'J').collect(); - assert_eq!(group, [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]); + assert_eq!(group, [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]); } #[test] diff --git a/crates/ui-egui/src/tools/shape_bounds_box.rs b/crates/ui-egui/src/tools/shape_bounds_box.rs new file mode 100644 index 0000000000..4814e11e2d --- /dev/null +++ b/crates/ui-egui/src/tools/shape_bounds_box.rs @@ -0,0 +1,264 @@ +//! A bounding box with eight handles over the selected shape layers, as in Photoshop: with +//! Path Selection or a shape tool (Rectangle, Ellipse, Triangle, Polygon, Line) active and one or +//! more shape layers selected, the union of their path bounds shows as a box. Dragging a handle +//! previews the scaled outlines and, on release, scales every selected shape about the opposite +//! handle with one `edit.transform` (which keeps live shapes live), as one History step named +//! "Transform Path". No Free Transform session opens, and there is no rotate zone. A corner +//! scales both ways, the pointer projected onto the box diagonal as Free Transform's corner does +//! (#11096); an edge middle scales one way. The corner radius widgets (`corner_radius`) keep +//! priority on a press. + +use egui::{Color32, CursorIcon, Modifiers, Pos2, Stroke, vec2}; +use photocraft_doc::LayerContent; +use photocraft_geom::Affine; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; +use crate::state::Tool; +use crate::tool_cursor; + +/// The scale a drag can reach, as `path.transform`'s. +const MIN_SCALE: f64 = 0.01; +const MAX_SCALE: f64 = 100.0; +/// A handle is grabbed within this many screen points. +const GRAB_PX: f64 = 7.0; + +/// A handle drag in progress. +#[derive(Clone, Debug, PartialEq)] +pub struct Drag { + /// The document and its revision at the press: a drag outlives neither a switch to another + /// document nor an edit under it (Undo, say). + doc: photocraft_doc::DocId, + revision: u64, + /// The tool it started with: a switch to another tool ends it, even one that shows the box. + tool: Tool, + /// The selected shape layers, as at the press. + layers: Vec, + /// Their union box `[x0, y0, x1, y1]` at the press. + rect: [f64; 4], + /// The grabbed handle: `-1` left or top, `1` right or bottom, `0` the middle. + handle: [i8; 2], + /// Where the press was. + start: [f64; 2], + /// The scale so far, per axis. + scale: [f64; 2], +} + +/// The tools that show the box, while View › Extras and View › Show › Target Path show paths +/// (the overlay draws it only then, so a hidden handle must not take a press either). +fn shows(app: &PhotocraftApp) -> bool { + app.ui.transform.is_none() + && app.ui.view.shows(app.ui.view.show.target_path) + && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) +} + +/// The selected shape layers and the box round their paths. `None` unless at least one layer is +/// selected and all are shape layers. +fn selection(app: &PhotocraftApp) -> Option<(Vec, [f64; 4])> { + let st = app.session.active()?; + let ids = st.selected_layers(); + if ids.is_empty() { + return None; + } + let mut layers = Vec::with_capacity(ids.len()); + let mut rect: Option<[f64; 4]> = None; + for id in ids { + let l = st.doc.layer(id)?; + let LayerContent::Shape(sh) = &l.content else { return None }; + let (x0, y0, x1, y1) = sh.path.control_bounds()?; + rect = Some(rect.map_or([x0, y0, x1, y1], |r| [r[0].min(x0), r[1].min(y0), r[2].max(x1), r[3].max(y1)])); + layers.push(l.id.0); + } + let r = rect.filter(|r| r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1])?; + Some((layers, r)) +} + +/// The eight handles of `r`: their `[hx, hy]` and where they sit. +fn handles(r: [f64; 4]) -> [([i8; 2], [f64; 2]); 8] { + let [x0, y0, x1, y1] = r; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + [ + ([-1, -1], [x0, y0]), + ([0, -1], [cx, y0]), + ([1, -1], [x1, y0]), + ([1, 0], [x1, cy]), + ([1, 1], [x1, y1]), + ([0, 1], [cx, y1]), + ([-1, 1], [x0, y1]), + ([-1, 0], [x0, cy]), + ] +} + +/// The point a handle's drag scales about: the opposite handle (the box's centre on a middle axis). +fn fixed_point(r: [f64; 4], handle: [i8; 2]) -> [f64; 2] { + let pick = |h: i8, lo: f64, hi: f64| match h { + 1 => lo, + -1 => hi, + _ => (lo + hi) / 2.0, + }; + [pick(handle[0], r[0], r[2]), pick(handle[1], r[1], r[3])] +} + +/// The scale a drag of `handle` by `d` document px gives the box `r`, per axis. A corner scales +/// both ways by the same factor, the drag projected onto the box diagonal; an edge middle scales +/// one way. +pub(crate) fn scale_for(r: [f64; 4], handle: [i8; 2], d: [f64; 2]) -> [f64; 2] { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + let clamp = |s: f64| if s.is_finite() { s.clamp(MIN_SCALE, MAX_SCALE) } else { 1.0 }; + match handle { + [0, 0] => [1.0, 1.0], + [hx, 0] => [clamp(1.0 + d[0] * f64::from(hx) / w), 1.0], + [0, hy] => [1.0, clamp(1.0 + d[1] * f64::from(hy) / h)], + [hx, hy] => { + let diag = [f64::from(hx) * w, f64::from(hy) * h]; + let s = clamp(1.0 + (d[0] * diag[0] + d[1] * diag[1]) / (diag[0] * diag[0] + diag[1] * diag[1])); + [s, s] + } + } +} + +/// `r` after scaling by `s` about `fixed`. +fn scaled_rect(r: [f64; 4], fixed: [f64; 2], s: [f64; 2]) -> [f64; 4] { + let at = |v: f64, f: f64, s: f64| f + (v - f) * s; + let (a, b) = (at(r[0], fixed[0], s[0]), at(r[2], fixed[0], s[0])); + let (c, d) = (at(r[1], fixed[1], s[1]), at(r[3], fixed[1], s[1])); + [a.min(b), c.min(d), a.max(b), c.max(d)] +} + +fn zoom(app: &PhotocraftApp) -> f64 { + f64::from(app.point_zoom().max(0.01)) +} + +/// The handle within grabbing distance of document point `p`, the nearest. +fn hit(app: &PhotocraftApp, p: [f64; 2]) -> Option<[i8; 2]> { + if !shows(app) { + return None; + } + let (_, r) = selection(app)?; + let tol = GRAB_PX / zoom(app); + handles(r).into_iter().map(|(h, at)| (h, (at[0] - p[0]).hypot(at[1] - p[1]))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(h, _)| h) +} + +/// The scale cursor over a handle at document point `p`, or while one is dragged, turned with the +/// view (its rotation, flip and pixel aspect) as Free Transform's. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2]) -> Option { + let (handle, r) = match app.ui.shape_box.as_ref() { + Some(d) => (d.handle, d.rect), + None => (hit(app, p)?, selection(app)?.1), + }; + let [x0, y0, x1, y1] = r; + let quad = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + Some(match handle { + [-1, -1] => tool_cursor::corner_resize(&quad, 0, xf), + [1, -1] => tool_cursor::corner_resize(&quad, 1, xf), + [1, 1] => tool_cursor::corner_resize(&quad, 2, xf), + [-1, 1] => tool_cursor::corner_resize(&quad, 3, xf), + [_, 0] => tool_cursor::resize([1.0, 0.0], xf), + _ => tool_cursor::resize([0.0, 1.0], xf), + }) +} + +/// Drop a handle drag without applying it (Esc, or its document or tool gone); the rest of the +/// press, its moves and release, does nothing. +pub fn cancel(app: &mut PhotocraftApp) { + if app.ui.shape_box.take().is_some() { + app.cancelled_vector_drag = true; + } +} + +/// Cancel a handle drag whose tool, document or document revision is no longer the one it +/// started on, or whose box is hidden now. Run every frame from `sync_views`. +pub fn cancel_stale(app: &mut PhotocraftApp) { + let Some(d) = app.ui.shape_box.as_ref() else { return }; + let current = app.session.active().is_some_and(|st| st.doc.id == d.doc && st.revision == d.revision); + if !current || app.ui.tool != d.tool || !shows(app) { + cancel(app); + } +} + +/// Canvas pointer events for the box; false when the event is not its. +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + match ev { + ToolEvent::Down { x, y, .. } => { + if mods.command { + return false; + } + let Some(handle) = hit(app, [x, y]) else { return false }; + let Some((layers, rect)) = selection(app) else { return false }; + let Some((doc, revision)) = app.session.active().map(|st| (st.doc.id, st.revision)) else { return false }; + app.ui.shape_box = Some(Drag { doc, revision, tool: app.ui.tool, layers, rect, handle, start: [x, y], scale: [1.0, 1.0] }); + // The press may have started the Move tool's snapping, which a box drag has none of. + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + app.move_mods = Default::default(); + true + } + ToolEvent::Move { x, y, .. } => { + let Some(d) = app.ui.shape_box.as_mut() else { return false }; + d.scale = scale_for(d.rect, d.handle, [x - d.start[0], y - d.start[1]]); + true + } + ToolEvent::Up { x, y } => { + let had = app.ui.shape_box.is_some(); + cancel_stale(app); + if app.ui.shape_box.is_none() { + // The release of a drag that was just dropped is still the box's: it must not + // reach the tool that is active now. + return had; + } + pointer(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods); + let Some(d) = app.ui.shape_box.take() else { return true }; + // A click without movement changes nothing. + if d.scale == [1.0, 1.0] { + return true; + } + let fixed = fixed_point(d.rect, d.handle); + let params = json!({ + "layers": d.layers, + "rect": d.rect, + "scaleX": d.scale[0] * 100.0, + "scaleY": d.scale[1] * 100.0, + "reference": fixed, + // Named by the command: with background jobs on, the step lands after `run` + // returns, so a rename here would rename the step before it. + "stepName": photocraft_engine::transform_cmds::TRANSFORM_PATH, + }); + if let Err(e) = app.run("edit.transform", params) { + app.ui.status = e; + app.ui.status_error = true; + } + true + } + } +} + +/// The box and its handles, and while a handle is dragged the scaled outlines. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { + if !shows(app) { + return; + } + let Some((_, rect)) = selection(app) else { return }; + let mut shown = rect; + if let Some(d) = app.ui.shape_box.as_ref() { + let fixed = fixed_point(d.rect, d.handle); + shown = scaled_rect(d.rect, fixed, d.scale); + let map = Affine { m: [d.scale[0], 0.0, 0.0, d.scale[1], fixed[0] * (1.0 - d.scale[0]), fixed[1] * (1.0 - d.scale[1])] }; + if let Some(st) = app.session.active() { + for id in &d.layers { + if let Some(LayerContent::Shape(sh)) = st.doc.layer(photocraft_doc::LayerId(*id)).map(|l| &l.content) { + crate::vector_ui::draw_outline(painter, &sh.path.transform(&map), to_scr, accent); + } + } + } + } + // Through all four corners: under a rotated view two opposite ones don't span the box. + let [x0, y0, x1, y1] = shown; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(to_scr); + painter.add(egui::Shape::closed_line(corners.to_vec(), Stroke::new(1.0, accent))); + for (_, at) in handles(shown) { + let h = egui::Rect::from_center_size(to_scr(at), vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index 6bc967e34e..1e50b63b47 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -194,6 +194,10 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), true, accent); } } + // Only over the active document, whose selected layers the box frames. + if st.is_some_and(|s| s.doc.id == doc.id) { + crate::shape_bounds_box::draw(app, painter, &to_scr, accent); + } crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While diff --git a/crates/ui-egui/src/view_guides_submenu_tests.rs b/crates/ui-egui/src/view_guides_submenu_tests.rs new file mode 100644 index 0000000000..ef9834cecf --- /dev/null +++ b/crates/ui-egui/src/view_guides_submenu_tests.rs @@ -0,0 +1,113 @@ +//! #11400: View › Guides holds the nine guide commands in the reference's order, and its two +//! Selected Guides rows act on the selected guide. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas_tool_menu::{GUIDE_CLEAR, GUIDE_EDIT, GUIDE_MENU}; + +const ORDER: [(&str, &str); 9] = [ + ("Edit Selected Guides", "view.editSelectedGuides"), + ("Lock Guides", "view.lockGuides"), + ("Clear Guides", "view.clearGuides"), + ("Clear Selected Guides", "view.clearSelectedGuides"), + ("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides"), + ("Clear Canvas Guides", "view.clearCanvasGuides"), + ("New Guide…", "view.newGuide"), + ("New Guide Layout…", "view.newGuideLayout"), + ("New Guides From Shape", "view.newGuidesFromShape"), +]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + app +} + +fn item(app: &PhotocraftApp, id: &str) -> crate::menus::MenuItem { + crate::menus::menu_items(app).into_iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} is not in the menu")) +} + +fn vertical(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.guides.vertical.clone() +} + +#[test] +fn guides_submenu_lists_the_nine_rows_in_order() { + let app = app(); + let path = vec!["View".to_string(), "Guides".to_string()]; + let rows: Vec<(String, String)> = crate::menus::menu_items(&app).into_iter().filter(|i| i.path == path).map(|i| (i.label, i.id)).collect(); + let want: Vec<(String, String)> = ORDER.iter().map(|(l, i)| (l.to_string(), i.to_string())).collect(); + assert_eq!(rows, want); + assert_eq!(item(&app, "view.lockGuides").shortcut.as_deref(), Some("Cmd+Alt+;")); +} + +#[test] +fn no_guide_row_is_left_flat_in_view() { + let app = app(); + let items = crate::menus::menu_items(&app); + let guides = vec!["View".to_string(), "Guides".to_string()]; + for (_, id) in ORDER { + let rows: Vec<_> = items.iter().filter(|i| i.id == id).collect(); + assert_eq!(rows.len(), 1, "{id} appears exactly once"); + assert_eq!(rows[0].path, guides, "{id} is in View > Guides, not flat in View"); + } +} + +#[test] +fn the_menu_cache_follows_the_selected_guide() { + let mut app = app(); + let none = crate::native_menu::state_hash(&app, None); + crate::guide_select::select(&mut app, true, 0); + let one = crate::native_menu::state_hash(&app, None); + assert_ne!(none, one, "selecting a guide changes the menu state"); + assert!(item(&app, "view.clearSelectedGuides").enabled); + crate::guide_select::clear(&mut app); + assert_eq!(crate::native_menu::state_hash(&app, None), none); + assert!(!item(&app, "view.clearSelectedGuides").enabled); +} + +#[test] +fn selected_rows_are_greyed_without_a_selected_guide_and_enabled_with_one() { + let mut app = app(); + for id in ["view.editSelectedGuides", "view.clearSelectedGuides"] { + assert!(!item(&app, id).enabled, "{id} with no guide selected"); + assert!(crate::menus::invoke(&mut app, &egui::Context::default(), id, json!({})).is_err(), "{id} errors, not a crash"); + } + crate::guide_select::select(&mut app, true, 0); + for id in ["view.editSelectedGuides", "view.clearSelectedGuides"] { + assert!(item(&app, id).enabled, "{id} with a guide selected"); + } +} + +#[test] +fn clear_selected_guides_removes_only_the_selected_guide() { + let mut app = app(); + crate::guide_select::select(&mut app, true, 1); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "view.clearSelectedGuides", json!({})).unwrap(); + assert_eq!(vertical(&app), vec![10.0]); +} + +#[test] +fn edit_selected_guides_opens_the_right_click_menus_dialog() { + let mut app = app(); + crate::guide_select::select(&mut app, true, 0); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "view.editSelectedGuides", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + let from_menu = app.ui.dialogs[0].fields.clone(); + let id = app.ui.dialogs[0].id; + crate::dialogs::cancel(&mut app, id).unwrap(); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields, from_menu); +} + +#[test] +fn right_click_guide_menu_keeps_both_selected_rows() { + let ids: Vec<&str> = GUIDE_MENU.iter().flatten().map(|(_, id)| *id).collect(); + assert!(ids.contains(&GUIDE_EDIT) && ids.contains(&GUIDE_CLEAR)); + assert_eq!((GUIDE_EDIT, GUIDE_CLEAR), ("view.editSelectedGuides", "view.clearSelectedGuides")); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cb5354e550..cf5e648190 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -42,7 +42,9 @@ pub use color::{ }; #[cfg(test)] use color::{checker_tile_id, checker_uv}; -pub use dropdown::{dropdown, dropdown_hovered, dropdown_literal, dropdown_wheel_hovered, dropdown_with_tooltips, menu_scroll, swatch_popup}; +pub use dropdown::{ + dropdown, dropdown_hovered, dropdown_literal, dropdown_wheel_grouped, dropdown_wheel_hovered, dropdown_with_tooltips, menu_scroll, swatch_popup, +}; pub(crate) use fields::compact_wrapping_value_field; #[cfg(test)] use fields::{FieldOpts, NUMERIC_INPUT_LIMIT, value_field_in}; diff --git a/crates/ui-egui/src/widgets/dropdown.rs b/crates/ui-egui/src/widgets/dropdown.rs index 6f83b0edd2..ca0ea37d67 100644 --- a/crates/ui-egui/src/widgets/dropdown.rs +++ b/crates/ui-egui/src/widgets/dropdown.rs @@ -1,7 +1,7 @@ use super::panel::chevron_icon; use super::scrub::take_label_scrub_name; use super::slider::wheel_notches_of; -use super::{Pos2, Rect, Response, Ui, Vec2, type_ahead}; +use super::{Pos2, Rect, Response, Sense, Stroke, Tokens, Ui, Vec2, type_ahead, vec2}; /// A compact labelled dropdown in the studio style. pub fn dropdown(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { @@ -11,13 +11,13 @@ pub fn dropdown(ui: &mut Ui, id: &str, name: &str, current /// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel /// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). pub fn dropdown_literal(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { - let (chosen, _) = dropdown_with(ui, id, name, current, options, width, false, false); + let (chosen, _) = dropdown_with(ui, id, name, current, options, width, false, false, &|_| false); !chosen.is_empty() } /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, name, current, options, width, false, true); + let (chosen, hovered) = dropdown_with(ui, id, name, current, options, width, false, true, &|_| false); (!chosen.is_empty(), hovered) } @@ -35,7 +35,22 @@ pub fn dropdown_wheel_hovered( options: &[(T, &str)], width: f32, ) -> (Vec, Option) { - dropdown_with(ui, id, name, current, options, width, true, true) + dropdown_with(ui, id, name, current, options, width, true, true, &|_| false) +} + +/// [`dropdown_wheel_hovered`] whose open list draws a line above each option (but the first) that +/// `starts_group`: Photoshop's blend mode lists group their modes (#5532). The lines are only +/// drawn: the options, their order and how the wheel, the keys and the pointer pick them stay. +pub fn dropdown_wheel_grouped( + ui: &mut Ui, + id: &str, + name: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + starts_group: impl Fn(&T) -> bool, +) -> (Vec, Option) { + dropdown_with(ui, id, name, current, options, width, true, true, &starts_group) } #[allow(clippy::too_many_arguments)] @@ -48,6 +63,7 @@ fn dropdown_with( width: f32, wheel: bool, translate: bool, + starts_group: &dyn Fn(&T) -> bool, ) -> (Vec, Option) { let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); @@ -86,7 +102,10 @@ fn dropdown_with( // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for ((v, _), text) in options.iter().zip(texts.iter()) { + for (i, ((v, _), text)) in options.iter().zip(texts.iter()).enumerate() { + if i > 0 && starts_group(v) { + list_separator(ui); + } let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); @@ -135,6 +154,15 @@ fn dropdown_with( (chosen, hovered) } +/// A line between two groups of an open dropdown list's options, as wide as the list. Like egui's +/// separator it takes no width while egui sizes the list, so it never widens it. +fn list_separator(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let width = if ui.is_sizing_pass() { 0.0 } else { ui.available_width() }; + let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter().hline(rect.x_range(), rect.center().y, Stroke::new(1.0, t.separator)); +} + /// Makes an open dropdown list as wide as its widest option is when hovered, from the start. egui /// takes a plain row's frame stroke off its padding but draws a hovered row with the stroke, so a /// row grows by two stroke widths under the pointer, and the list never shrinks back: scrolling diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index d4c1bd81b3..9dd705f638 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -1442,3 +1442,44 @@ fn the_wheel_leaves_a_plain_dropdown_alone() { wheel(&mut h, egui::MouseWheelUnit::Line, egui::vec2(0.0, -1.0), Modifiers::NONE); assert_eq!(*h.state(), 0); } + +/// [`MODES`] in a wheel dropdown whose list draws a line above each option `starts_group` picks, +/// opened: the open list's rect and each option's row in it. +fn open_grouped_modes(starts_group: fn(&usize) -> bool) -> (egui::Rect, Vec) { + let mut h = Harness::builder().with_size(egui::vec2(300.0, 300.0)).build_ui_state( + move |ui, selected: &mut usize| { + super::dropdown_wheel_grouped(ui, "blend-mode", tl!("Blend Mode"), selected, &MODES, 120.0, starts_group); + }, + 0, + ); + crate::theme::apply(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(4); + let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))); + let Some(list) = list else { panic!("the list is open") }; + let rows = MODES.iter().map(|(_, l)| h.get_all_by_label(l).map(|n| n.rect()).rfind(|r| list.contains_rect(*r)).unwrap_or(egui::Rect::NOTHING)).collect(); + (list, rows) +} + +/// The gaps between consecutive rows of an open list. +fn row_gaps(rows: &[egui::Rect]) -> Vec { + rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect() +} + +/// A grouped dropdown draws a line above each option starting a group, never above the first one, +/// and the lines take no width: the list is as wide as without them (#5532). +#[test] +fn a_grouped_dropdown_list_draws_a_line_above_each_group_and_keeps_its_width() { + let (plain_list, plain) = open_grouped_modes(|_| false); + let plain_gaps = row_gaps(&plain); + let (lo, hi) = plain_gaps.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), g| (lo.min(*g), hi.max(*g))); + assert!(hi - lo < 1.0, "no groups, even row gaps: {plain_gaps:?}"); + // Darken (index 2) starts a group; so does Normal, the first option, which never gets a line. + let (list, rows) = open_grouped_modes(|i| matches!(*i, 0 | 2)); + let gaps = row_gaps(&rows); + assert!((gaps[0] - lo).abs() < 0.5 && (gaps[2] - lo).abs() < 0.5, "only Dissolve/Darken is wider: {gaps:?}"); + assert!(gaps[1] > lo + 2.0, "a line between Dissolve and Darken: {gaps:?}"); + assert!((rows[0].top() - plain[0].top()).abs() < 0.5, "no line above the first row: {rows:?} vs {plain:?}"); + assert_eq!(list.width(), plain_list.width(), "the line doesn't widen the list"); +} diff --git a/docs/atn.md b/docs/atn.md index 0e72430eeb..3dfe9a658b 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -90,6 +90,7 @@ row; the action still adds only one Undo entry. | `Btch` | batch | Batch (supported folder and naming forms) | `file.automate.batch` | | (string ID only) | shapeBlur | Shape Blur (custom shape by installed ID) | `filter.blur.shapeBlur` | | (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` | +| (string ID only) | perspectiveWarpTransform | Perspective Warp (indexed layout and warp planes) | `edit.perspectiveWarp` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -213,6 +214,8 @@ row; the action still adds only one Undo entry. | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | +| (string ID only) | applyImageStackPluginRenderer | Smart Object Stack Mode | `layer.smartObjects.stackMode.*` (eleven measured modes) | +| (string ID only) | imageStackConvertSmartObject | Stack Mode: None | `layer.smartObjects.stackMode.none` | | (string ID only) | vibrance | Vibrance | `image.adjustments.vibrance` | | `Wave` | wave | Wave | `filter.distort.wave` | | `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | @@ -227,6 +230,9 @@ row; the action still adds only one Undo entry. | (string ID only) | placedLayerUpdateAllModified | Update All Modified Content | `layer.smartObjects.updateAllModifiedContent` | | (string ID only) | rasterizeAll | Rasterize All Layers | `layer.rasterize.allLayers` | | (string ID only) | addBlankVideoLayer | New Blank Video Layer | `layer.videoLayers.newBlankVideoLayer` | +| (string ID only) | insertBlankFrame | Insert Blank Frame | `layer.videoLayers.insertBlankFrame` | +| (string ID only) | duplicateFrame | Duplicate Frame | `layer.videoLayers.duplicateFrame` | +| (string ID only) | deleteFrame | Delete Frame | `layer.videoLayers.deleteFrame` | | (string ID only) | isolateLayers | Isolate Layers | `select.isolateLayers` | | (string ID only) | textLoadDefaultTypeStyles | Load Default Type Styles | `type.loadDefaultTypeStyles` | | (string ID only) | textSaveDefaultTypeStyles | Save Default Type Styles | `type.saveDefaultTypeStyles` | @@ -278,6 +284,19 @@ identity, and is omitted on save under the normalization rule below. Undecodable paths or extra keys keep the entire step. File access happens at playback; a missing or unusable file reports the native command's error. +Stack Mode uses the active Smart Object. For `applyImageStackPluginRenderer`, +the measured `imageStackPlugin` class selects the mode; the localized `Nm ` +label is preserved on save. Stack Mode: None uses +`imageStackConvertSmartObject` with no settings. Save preserves whether an +imported None step had an empty descriptor or no descriptor. Unknown classes, +extra settings and unrecognized descriptor forms stay as kept steps. Native +steps with explicit layer targets cannot be exported to these forms. + +Insert Blank Frame, Duplicate Frame and Delete Frame use the active video layer +and current playhead. They take no settings. Extra keys or unrecognized +descriptor metadata stay as kept steps. Save retains whether the recorded item +had an empty descriptor or no descriptor. + Convert Type to Shape retains Make's `contentLayer` class reference and `Usng` target `textLayer` reference; it does not export a separate `convertToShape` event. Other Make mappings, including Solid Color and Pattern fills, are unchanged. @@ -295,6 +314,24 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step. Playback stops at that enabled helper before subsequent duplicate/crop steps can run. This covers the parent event, not complete recordings with generated helpers. +### Perspective Warp recordings + +`perspectiveWarpTransform` maps Layout and Warp to one `edit.perspectiveWarp` +step. It warps a raster layer in place or adds a Perspective Warp Smart Filter to +a Smart Object. The active layer, a named layer or the Background can be the +recorded target. + +The recorded fields are `null` (layer target), `Md ` (`perspectiveWarpMode`: +`warp`), `referenceRect` (pixel rectangle), `vertices`, `warpedVertices` (document +pixel points), and `quads` (four indices per plane). Unchanged steps save the +original descriptor, including its reference rectangle, vertex order and shared +indices. Playback uses the recorded document-pixel coordinates. A native export +requires an explicit reference rectangle and representable planes. + +Unsupported targets, modes, units, degenerate quads and meshes with different +corner sharing stay kept steps. Export refuses a changed recorded mesh if the +stored descriptor no longer describes it. + ### Content-Aware Fill recordings `cafWorkspace` maps Rectangular / Default color adaptation / None rotation with New Layer or @@ -944,7 +981,6 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | | Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | | Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | -| Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. | | Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | | Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | | Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e118ddf51e..6cf6bf3956 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -138,7 +138,7 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` | | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | -| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`; id 0 is the document as opened), selection bounds | +| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`; id 0 is the document as opened, unless `history.options` `automaticallyCreateFirstSnapshot` is off; with `automaticallyCreateNewSnapshotWhenSaving` on, each save of a changed document adds one named `HH:MM:SS`, or `Snapshot N` in the browser; every snapshot keeps its copy of the document for the session: its tiles count against the History & Cache budget by dropping the oldest history states, but snapshots are never dropped themselves), selection bounds | | `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA, four floats 0..1 (not 0..255) | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ccc77e95bc..2bf3a70644 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,11 +9,11 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho | File | 52 | 52 | 100% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | -| Layer | 163 | 163 | 100% | +| Layer | 161 | 161 | 100% | | Type | 74 | 74 | 100% | | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | -| View | 74 | 74 | 100% | +| View | 76 | 76 | 100% | | Window | 61 | 61 | 100% | ## Not yet implemented diff --git a/docs/scorecard.md b/docs/scorecard.md index bf6ebcfb9b..dfba8d2e32 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 189 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 660 of 660 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 189** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 192** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80664d314b..58a5a317be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -246,9 +246,9 @@ Paragraph; the rest open as in-app floating windows. | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | | Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar | -| Every preference takes effect | partial | 11 of 183 do nothing (scorecard, 2026-11-01) | +| Every preference takes effect | partial | 10 of 192 do nothing (scorecard, 2026-11-06) | | UI scale presets | done | UI-217-9; 75–125% in 5% steps (#2492, #3222) | -| History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; the snapshot History Options and History Brush sources to come (#2966) | +| History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands; History Options' snapshot options (Automatically Create First Snapshot, New Snapshot When Saving, Show New Snapshot Dialog by Default) | partial | `history_cmds.rs`, `history_options_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; History Brush sources to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | @@ -263,11 +263,13 @@ Paragraph; the rest open as in-app floating windows. | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | +| Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…; the single-channel items act on the targeted row (a colour, alpha or spot channel, Quick Mask, the layer mask, a one-channel image's composite) and are greyed without one; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… (only the modes the open grayscale documents fill, a different document per channel; greyed below three documents) open their dialogs, here and in the rows' menu, and Split Channels runs at once | done (2026-11-06) | `channels_panel.rs` `panel_menu`, `channels_panel/tests.rs`, `panel_docking/tests.rs`, `dialogs/merge_channels_ui.rs`; #11451. Panel Options (thumbnail size) to come | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed | @@ -328,6 +330,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | +| 2026-10-12 | minor | History Options: Automatically Create First Snapshot, Automatically Create New Snapshot When Saving, Show New Snapshot Dialog by Default (#2966) | | 2026-10-12 | minor | Crop tool Fill › Content-Aware Fill done (#3823); Delete Cropped Pixels off split into its own row, still partial | | 2026-10-12 | minor | Layers panel: Clip Thumbnails to Layer Bounds frames each layer's pixels instead of the canvas (#4281) | | 2026-10-12 | minor | Guides: ⌥-click turns a guide through the click; double-click opens Preferences › Guides, Grid & Slices |