diff --git a/contributors/people.toml b/contributors/people.toml index b5084209a4..f8a49ee90e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.brianblakely] +sync_github_name = true + [people.jonathanwxh-cell] display_name = "jonathnwxh" sync_github_name = false diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index bb6b1d4b1b..ee678d1aa9 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -19,6 +19,7 @@ //! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and //! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things. +use crate::par; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; @@ -186,6 +187,10 @@ struct StrokeCursor { carry: f64, } +/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about +/// 125,000); smaller ones are not worth the hand-off. +const PARALLEL_DAB_NODES: usize = 16_384; + /// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a /// drag is many small pushes and its edge stays smooth. fn dab_spacing(r: f64) -> f64 { @@ -487,9 +492,11 @@ impl LiquifyField { bilinear(&self.d, self.w, self.h, gx, gy) } }; - let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1)); + // Every node reads only the snapshot and the field as it was before this dab (the writes + // land after), so rows are independent: a large brush computes them in parallel. let inv_cell = 1.0 / self.cell; - for j in j0..=j1 { + let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> { + let mut row = Vec::with_capacity(i1 - i0 + 1); for i in i0..=i1 { let p = self.node_pos(i, j); let (dx, dy) = (p[0] - c[0], p[1] - c[1]); @@ -503,7 +510,7 @@ impl LiquifyField { if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw { let f = self.freeze[idx]; let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) }; - writes.push((idx, self.d[idx], v as f32)); + row.push((idx, self.d[idx], v as f32)); continue; } let k = k_raw * (1.0 - f64::from(self.freeze[idx])); @@ -553,10 +560,13 @@ impl LiquifyField { [(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32] } }; - writes.push((idx, new, self.freeze[idx])); + row.push((idx, new, self.freeze[idx])); } - } - for (idx, d, f) in writes { + row + }; + let rows = j1 - j0 + 1; + let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row)); + for (idx, d, f) in writes.into_iter().flatten() { self.d[idx] = d; self.freeze[idx] = f; } @@ -653,7 +663,9 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { if field.is_identity() || b.is_empty() { return out; } - let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3; + // A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D + // interpolated displacement can reach 25/16 of the largest nodal displacement. + let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3); let has_freeze = field.freeze.iter().any(|&f| f > 0.0); let tiles: Vec = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect(); let work = |t: &Rect| -> Option<(Rect, Vec)> { @@ -929,18 +941,23 @@ impl ProxyImage { return false; } source_scratch.resize(source_len, [0.0; 4]); - src.read_rgba_into(src_rect, source_scratch); - if !src.format().alpha { - for pixel in source_scratch.iter_mut() { - pixel[3] = 1.0; + // Read and premultiply the source in one pass per band of rows, the bands in parallel: at + // an 800 px brush it is about a million pixels (P30). + let opaque = !src.format().alpha; + const BAND_ROWS: usize = 32; + par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| { + let y = src_rect.y0 + (band_index * BAND_ROWS) as i32; + src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band); + for pixel in band.iter_mut() { + if opaque { + pixel[3] = 1.0; + } + let alpha = pixel[3]; + pixel[0] *= alpha; + pixel[1] *= alpha; + pixel[2] *= alpha; } - } - for pixel in source_scratch.iter_mut() { - let alpha = pixel[3]; - pixel[0] *= alpha; - pixel[1] *= alpha; - pixel[2] *= alpha; - } + }); let x0 = (region.x0 - bounds.x0) as usize; let x1 = (region.x1 - bounds.x0) as usize; diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index 61ccfc0038..aa76fb814a 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -124,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output #[cfg(test)] mod tests { use super::*; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) { + let b = Rect::new(-2, -1, 3, 4); + let format = PixelFormat::new(mode, depth, alpha); + let mut src = Surface::new(format); + let mut pixels = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + for c in 0..format.channels() { + pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0); + } + } + } + src.write_region(b, &pixels); + (src, b) + } + + fn assert_sample(actual: f32, expected: f32, depth: SampleType) { + let (expected, tolerance) = match depth { + SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0), + SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0), + SampleType::F32 => (expected, 1e-6), + }; + assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}"); + } + + fn run_custom(src: &Surface, b: Rect, kernel: Vec, scale: f32, offset: f32) -> Surface { + crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b) + } + + #[test] + fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + for tap in 0..25 { + let mut kernel = vec![0.0; 25]; + kernel[tap] = 1.0; + let out = run_custom(&src, b, kernel, 1.0, 0.0); + let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1)); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], expected[c], depth); + } + assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift"); + } + } + } + } + } + + #[test] + fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let mut short = vec![0.0; 13]; + short[0] = 0.25; + short[12] = 0.75; + let mut padded = short.clone(); + padded.resize(25, 0.0); + let mut long = padded.clone(); + long.extend([1e6, -1e6, f32::NAN]); + let out = run_custom(&src, b, short, 1.0, 0.0); + assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b)); + assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0)); + let centre = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth); + } + } + } + } + } + + #[test] + fn custom_scale_zero_is_one_and_offset_is_in_255ths() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let before = src.read_region(b); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0); + assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() { + for depth in DEPTHS { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let (src, b) = custom_fixture(depth, mode, true); + let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0); + let alpha = src.channels() - 1; + assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour"); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}"); + } + } + } + } + } + + #[test] + fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() { + for depth in DEPTHS { + for alpha in [false, true] { + let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha); + for offset in [-51.0, 51.0] { + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, offset); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..4 { + // Twice the reflected brightness, plus offset, then back to ink. + assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth); + } + if alpha { + assert_eq!(actual[4], original[4]); + } + } + } + } + } + } + } + + #[test] + fn custom_float_retains_hdr_while_integer_storage_clamps() { + let b = Rect::new(0, 0, 1, 1); + for depth in DEPTHS { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, -51.0); + let actual = out.pixel(0, 0); + let original = src.pixel(0, 0); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + if depth == SampleType::F32 { + assert!(actual[0] < 0.0 && actual[2] > 1.0); + } else { + assert_eq!((actual[0], actual[2]), (0.0, 1.0)); + } + } + } #[test] fn hsb_hsl_round_trip() { diff --git a/crates/algo/src/video.rs b/crates/algo/src/video.rs index bbd093b0a8..512768fb7a 100644 --- a/crates/algo/src/video.rs +++ b/crates/algo/src/video.rs @@ -88,3 +88,162 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec { } res } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FilterParams, apply}; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn tolerance(depth: SampleType) -> f32 { + match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + } + } + + fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for (row, px) in pixels.iter().enumerate() { + let y = bounds.y0 + row as i32; + src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px); + } + src + } + + #[test] + fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() { + // Odd document origin: parity is relative to the bounds, not absolute y. + let b = Rect::new(-2, 11, 0, 16); + let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]); + for depth in DEPTHS { + let src = rows(depth, b, &pixels); + let before = src.read_region(b); + for (eliminate_even, interpolate, neighbours) in [ + (false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]), + (false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]), + (true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]), + (true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]), + ] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + for (row, (a, c)) in neighbours.into_iter().enumerate() { + for x in b.x0..b.x1 { + let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c)); + let actual = out.pixel(x, b.y0 + row as i32); + for channel in 0..4 { + let expected = (pa[channel] + pc[channel]) / 2.0; + assert!( + (actual[channel] - expected).abs() <= tolerance(depth), + "{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}" + ); + } + } + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn deinterlace_interpolation_weights_colour_by_alpha() { + let b = Rect::new(0, 0, 1, 3); + for depth in DEPTHS { + for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] { + let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]); + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None); + let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]); + let total = a + c; + let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] }; + let actual = out.pixel(0, 1); + for (v, want) in actual.iter().zip(expected) { + assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}"); + } + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 2), src.pixel(0, 2)); + } + } + } + + #[test] + fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() { + for depth in DEPTHS { + for height in [1, 2] { + let b = Rect::new(0, -3, 1, -3 + height); + let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + src.fill_rect(b, &[0.2]); + if height == 2 { + src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]); + } + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 }; + for y in b.y0..b.y1 { + assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}"); + } + } + } + } + } + } + + #[test] + fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() { + let area = Rect::new(0, 0, 1, 5); + let b = Rect::new(0, 1, 1, 4); + let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]); + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None); + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 4), src.pixel(0, 4)); + } + } + } + + #[test] + fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() { + let b = Rect::new(0, 0, 1, 5); + for depth in DEPTHS { + let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}"); + } + } + + fn luma_chroma(p: &[f32]) -> (f32, f32) { + let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; + let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2]; + let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2]; + (y, i.hypot(q)) + } + + #[test] + fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() { + // Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range. + let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5); + let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]]; + let b = Rect::new(0, 0, 1, colours.len() as i32); + for depth in DEPTHS { + let src = rows(depth, b, &colours); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + // The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step. + let eps = 0.0003 + 2.0 * tolerance(depth); + for y in b.y0..b.y1 { + let (before, after) = (src.pixel(0, y), out.pixel(0, y)); + let (y0, c0) = luma_chroma(&before); + let (y1, c1) = luma_chroma(&after); + assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction"); + assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}"); + assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}"); + assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}"); + assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0)); + assert_eq!(after[3], before[3], "alpha changed: {depth:?}"); + } + } + } +} diff --git a/crates/algo/tests/liquify_source_footprint.rs b/crates/algo/tests/liquify_source_footprint.rs new file mode 100644 index 0000000000..471df82f3e --- /dev/null +++ b/crates/algo/tests/liquify_source_footprint.rs @@ -0,0 +1,108 @@ +//! Liquify source reads must depend on the mapping, not the output tile phase. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Keeps the document size fixed while moving its edges relative to global tiles. +fn bounds(shift: i32) -> Rect { + Rect::new(shift, 0, shift + 1024, 512) +} + +/// Builds translated copies of RGB ramps and alternating columns so missing +/// interpolation taps change the sampled colors. +fn source(sample: SampleType, shift: i32) -> Surface { + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false)); + let mut row = vec![0.0; 1024 * 3]; + for y in 0..512 { + for (x, pixel) in row.chunks_exact_mut(3).enumerate() { + pixel.copy_from_slice(&[x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0]); + } + surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row); + } + surface +} + +/// Creates matching brush paths in document-relative coordinates, optionally +/// beside a frozen region that gives the displacement field a sharp boundary. +fn strokes(shift: i32, masked: bool) -> Vec { + let offset = f64::from(shift); + let mut strokes = Vec::new(); + if masked { + let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + freeze.amount = Some(1.0); + freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]]; + strokes.push(freeze); + } + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0); + warp.density = 100.0; + warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]]; + strokes.extend(std::iter::repeat_n(warp, 8)); + for stroke in &strokes { + stroke.validate().expect("the fixture uses supported brush parameters"); + } + strokes +} + +/// Checks that a half-tile translation preserves editable output across sample +/// depths and mesh sizes when image content and brush paths move together. +fn check_tile_translation(masked: bool) { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let translated = source(sample, 128); + for cell in [2.0, 4.0] { + let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked)); + let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked)); + if masked { + assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values"); + } + // The reported pixels lie entirely on the editable side of the mask. + assert_eq!(field.freeze_at(509.5, 220.5), 0.0); + assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0); + let output = apply_liquify(&original, &field); + let shifted_output = apply_liquify(&translated, &shifted_field); + assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip"); + let mut worst = 0.0f32; + // Translation changes which output tile contains this strip, while + // preserving the relative source image, brush path and document edges. + for y in 220..290 { + for x in 509..512 { + let a = output.pixel(x, y); + let b = shifted_output.pixel(x + 128, y); + for (a, b) in a.iter().zip(&b) { + worst = worst.max((a - b).abs()); + } + } + } + // Coordinate translation can perturb the last few F32 arithmetic bits. + // This is far smaller than the clipped source-stencil error. + let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 }; + assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}"); + } + } +} + +#[test] +fn cubic_source_footprint_is_independent_of_output_tile_phase() { + check_tile_translation(true); +} + +#[test] +fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() { + check_tile_translation(false); +} + +#[test] +fn source_reads_still_clamp_at_the_document_edge() { + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0); + warp.density = 100.0; + warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]]; + warp.validate().expect("the edge fixture uses supported brush parameters"); + let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]); + assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge"); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let output = apply_liquify(&original, &field); + assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge"); + } +} diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index e6df63cd09..88d62f6595 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -774,11 +774,12 @@ async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_au let client = connect(PhotocraftMcp::headless()).await; let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await); json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await); - run("layer.smartObjects.convertToSmartObject", json!({})).await; let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone(); // An explicit target works even with a regular pixel layer selected. run("layer.new.layer", json!({"name": "Other layer"})).await; assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1); + // A smart object cannot be converted again, so nest one inside the contents instead. + run("layer.smartObjects.convertToSmartObject", json!({})).await; assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2); run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await; assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 9bbc897149..d52522ca35 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -851,30 +851,35 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result { return Ok(Value::Null); } s.edit("Bit Depth", |doc, _| { - // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured - // in Photoshop 27.11), so the colour samples cross the document's tone curve when the - // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear - // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. - let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); - let to_linear = depth == SampleType::F32; - let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); - if crosses && !to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); - } - convert_layers_depth(&mut doc.layers, depth); - if crosses && to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); - } - for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - let f = ch.surface.format().with_sample(depth); - ch.surface = ch.surface.convert(f); - } - doc.depth = depth; + change_depth(doc, depth); Ok(()) })?; Ok(Value::Null) } +pub(crate) fn change_depth(doc: &mut Document, depth: SampleType) { + let current = doc.depth; + // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured + // in Photoshop 27.11), so the colour samples cross the document's tone curve when the + // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear + // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. + let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); + let to_linear = depth == SampleType::F32; + let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); + if crosses && !to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); + } + convert_layers_depth(&mut doc.layers, depth); + if crosses && to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); + } + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + let f = ch.surface.format().with_sample(depth); + ch.surface = ch.surface.convert(f); + } + doc.depth = depth; +} + /// Image → Duplicate. fn duplicate(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index d337e64d3f..84b3b8f7d6 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex}; use photocraft_algo::resample::translate_surface; use photocraft_algo::transform::Homography; -use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartContentsId, SmartFilter, SmartObject, SmartSource}; use photocraft_geom::{Affine, Rect, Size}; use photocraft_raster::Surface; @@ -194,6 +194,30 @@ fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surf s } +fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { + let gray = fmt.mode == ColorMode::Grayscale && src.pixel_format().mode != ColorMode::Grayscale; + let linear = fmt.sample == SampleType::F32 && src.depth != SampleType::F32; + if !gray && !linear { + return Ok(buffer_to_surface(buf, fmt)); + } + let mut doc = Document::new(src.name.clone(), src.size, src.mode, src.depth); + doc.icc_profile = src.icc_profile.clone(); + doc.layers = vec![Layer::new("source", LayerContent::Raster(buffer_to_surface(buf, src.pixel_format())))]; + if gray { + let gray_profile = crate::color_cmds::working_profile(ColorMode::Grayscale); + crate::color_cmds::convert_document(&mut doc, &gray_profile, photocraft_cms::Intent::RelativeColorimetric, true)?; + } + if linear { + crate::image_cmds::change_depth(&mut doc, SampleType::F32); + } + let surface = doc.layers.pop().and_then(|l| match l.content { + LayerContent::Raster(s) => Some(s), + _ => None, + }); + let surface = surface.ok_or_else(|| other("the smart object's source could not be converted"))?; + Ok(if surface.format() == fmt { surface } else { surface.convert(fmt) }) +} + /// An SVG source rendered at `scale` times its own size (cached per scale), so a vector smart /// object stays sharp however it is placed. pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result { @@ -229,7 +253,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result Stat::Variance, }; let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) }; - let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds }; + let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds }; cache_put(key, img.clone()); Ok(img) } @@ -481,11 +505,22 @@ pub fn render_below_filter(doc: &Document, sm: &SmartObject, index: usize) -> Re } /// Re-renders smart layer `l` (which lives in `doc`) in place. Returns false if its source is -/// unavailable (the cache is then left alone). +/// unavailable or a visible filter can't be applied (the cache is then left alone). pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result { + if matches!(&l.content, LayerContent::Smart(sm) if !unapplied_filters(sm).is_empty()) { + return Ok(false); + } refresh_layer_in(doc, l, &JobCtx::new()) } +fn unapplied_filters(sm: &SmartObject) -> Vec { + sm.smart_filters + .iter() + .filter(|f| sm.filters_enabled && f.visible && crate::commands::find(&f.command).is_none()) + .map(|f| f.params.get("name").and_then(Value::as_str).unwrap_or(&f.command).to_string()) + .collect() +} + fn refresh_layer_in(doc: &Document, l: &mut Layer, ctx: &JobCtx) -> Result { let LayerContent::Smart(sm) = &mut l.content else { return Ok(false) }; match render_in(doc, sm, ctx)? { @@ -784,8 +819,11 @@ fn convert(s: &mut Session, p: &Value) -> Result { // As with Group Layers, place the result in the top selected root's parent. Insert // before removal so paths shifting in other parents cannot discard unselected content. doc.insert_above(Some(top), so); - for id in ids { - doc.remove(id).ok_or(EngineError::NoLayer(id))?; + for id in &ids { + doc.remove(*id).ok_or(EngineError::NoLayer(*id))?; + } + if let [only] = ids[..] { + retarget_layer_refs(doc, only, new_id); } *active = Some(new_id); Ok(new_id) @@ -794,6 +832,25 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": new_id.0})) } +fn retarget_layer_refs(doc: &mut Document, from: LayerId, to: LayerId) { + let Some((fill_opacity, effects)) = doc.layer(to).map(|l| (l.fill_opacity, l.effects.clone())) else { return }; + for comp in doc.layer_comps.iter_mut().chain(doc.last_document_state.as_mut()) { + for state in comp.states.iter_mut().filter(|state| state.layer == from) { + state.layer = to; + if let Some(appearance) = &mut state.appearance { + appearance.fill_opacity = fill_opacity; + appearance.effects = effects.clone(); + } + } + } + for def in doc.variables.defs.iter_mut().filter(|def| def.layer == from) { + def.layer = to; + } + for slice in doc.slices.list.iter_mut().filter(|slice| slice.layer == Some(from)) { + slice.layer = Some(to); + } +} + // ---------- contents ---------- fn via_copy(s: &mut Session, p: &Value) -> Result { @@ -837,24 +894,27 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id, changed) = s.edit(label, |doc, _| { + let (parent, contents_id, changed, warnings) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; let changed = instances(doc, contents_id); + let mut warnings = std::collections::BTreeSet::new(); for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } + warnings + .extend(unapplied_filters(smart(doc, instance)?).into_iter().map(|n| format!("the {n} smart filter is not supported, so it was not applied"))); refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id, changed)) + Ok((doc.id, contents_id, changed, warnings)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the // command once per copy (#4075). - Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>(), "warnings": warnings})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { @@ -1253,6 +1313,13 @@ fn not_smart(s: &Session) -> std::result::Result<(), String> { if active_smart(s).is_ok() { Err("the layer is already a smart object".into()) } else { Ok(()) } } +/// Convert to Smart Object: refused only when the single targeted layer is already a smart +/// object. A multi-layer selection can still be combined (nesting any smart object in it). +fn convertible_to_smart(s: &Session) -> std::result::Result<(), String> { + convertible(s)?; + if crate::layer_multi_cmds::selected(s).len() <= 1 { not_smart(s) } else { Ok(()) } +} + macro_rules! spec { ($id:literal, $label:literal, $menu:expr, $params:literal, $en:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: $en, run: $run, journal: true } @@ -1269,7 +1336,7 @@ pub fn specs() -> Vec { "Convert to Smart Object", SO, r##"{"layer":id?} (default: combine selected layers)"##, - convertible, + convertible_to_smart, convert ), spec!( diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 76ca1aa899..ed8bce5996 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -149,6 +149,26 @@ fn empty_layer_cannot_be_converted() { assert!(s.execute("layer.smartObjects.convertToSmartObject", json!({})).is_err()); } +#[test] +fn convert_to_smart_object_is_disabled_for_a_layer_that_is_already_smart() { + let mut s = session(8); + paint(&mut s); + assert!(s.is_enabled("layer.smartObjects.convertToSmartObject")); + convert(&mut s); + assert!(!s.is_enabled("layer.smartObjects.convertToSmartObject")); +} + +#[test] +fn convert_to_smart_object_stays_enabled_for_a_selection_that_includes_a_smart_object() { + let mut s = session(8); + paint(&mut s); + let smart_id = LayerId(convert(&mut s)); + s.execute("layer.new.layer", json!({})).unwrap(); + let other_id = s.active().unwrap().active_layer.unwrap(); + crate::layer_multi_cmds::set_selection(&mut s, vec![smart_id, other_id], Some(other_id), None).unwrap(); + assert!(s.is_enabled("layer.smartObjects.convertToSmartObject")); +} + #[test] fn smart_filters_are_re_editable() { for depth in [8, 16] { diff --git a/crates/engine/tests/convert_keeps_references.rs b/crates/engine/tests/convert_keeps_references.rs new file mode 100644 index 0000000000..a9859d912e --- /dev/null +++ b/crates/engine/tests/convert_keeps_references.rs @@ -0,0 +1,47 @@ +use photocraft_doc::{LayerId, SliceOrigin}; +use photocraft_engine::Session; +use serde_json::json; + +fn art() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Art"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({})).unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "v", "layer": id.0, "type": "visibility"}]})).unwrap(); + (s, id) +} + +fn references_point_at(s: &Session, id: LayerId) -> bool { + let doc = &s.active().unwrap().doc; + let slice = doc.slices.list.iter().any(|sl| sl.origin == SliceOrigin::Layer && sl.layer == Some(id)); + let comps = doc.layer_comps.iter().all(|c| c.states.iter().any(|st| st.layer == id)); + let vars = doc.variables.defs.iter().all(|d| d.layer == id); + slice && comps && vars +} + +#[test] +fn converting_a_layer_keeps_its_slice_comps_and_variables() { + for cmd in ["layer.smartObjects.convertToSmartObject", "filter.convertForSmartFilters"] { + let (mut s, id) = art(); + s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + s.execute(cmd, json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + assert!(references_point_at(&s, smart), "{cmd} left references on the old layer"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(references_point_at(&s, id), "{cmd}: undo restores the original references"); + } +} + +#[test] +fn a_comp_does_not_put_the_converted_layers_style_on_the_smart_object() { + let (mut s, _) = art(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + let r = s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + let comp = r["comp"].as_u64().unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute("layerComp.apply", json!({"comp": comp})).unwrap(); + assert!(s.active().unwrap().doc.layer(smart).unwrap().effects.items.is_empty(), "the style stays inside the smart object"); +} diff --git a/crates/engine/tests/smart_object_tones.rs b/crates/engine/tests/smart_object_tones.rs new file mode 100644 index 0000000000..3c57526bad --- /dev/null +++ b/crates/engine/tests/smart_object_tones.rs @@ -0,0 +1,54 @@ +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_raster::Surface; +use serde_json::json; + +fn mean_difference(a: &Surface, b: &Surface) -> f64 { + let r = a.content_bounds().intersect(&b.content_bounds()); + let (mut sum, mut n) = (0f64, 0u32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (p, q) = (a.rgba(x, y), b.rgba(x, y)); + for c in 0..3 { + sum += f64::from((p[c] - q[c]).abs()); + n += 1; + } + } + } + sum / f64::from(n.max(1)) * 255.0 +} + +fn converted(cmd: &str) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [60, 40]], "size": 20, "hardness": 0.3, "color": "#e04a20"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 40], [60, 5]], "size": 14, "hardness": 0.6, "color": "#2a7fd0"})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute(cmd, json!({})).unwrap(); + (s, pixel, smart) +} + +#[test] +fn smart_objects_re_render_with_the_tones_of_the_converted_document() { + for cmd in ["image.mode.bits32", "image.mode.grayscale"] { + let (s, pixel, smart) = converted(cmd); + let st = s.active().unwrap(); + let layer = st.doc.layer(smart).unwrap().clone(); + let mut rerendered = layer.clone(); + smart_cmds::refresh_layer(&st.doc, &mut rerendered).unwrap(); + let (LayerContent::Smart(stored), LayerContent::Smart(fresh)) = (&layer.content, &rerendered.content) else { panic!("not a smart object") }; + let (stored, fresh) = (stored.cache.as_ref().unwrap(), fresh.cache.as_ref().unwrap()); + let reference = st.doc.layer(pixel).unwrap().surface().unwrap(); + let drift = mean_difference(stored, fresh); + assert!(drift < 1.0, "{cmd}: a re-render drifts {drift:.2}/255 from the converted rendering"); + let (off, baseline) = (mean_difference(fresh, reference), mean_difference(stored, reference)); + assert!( + off <= baseline + 0.5, + "{cmd}: the re-render is {off:.2}/255 from a pixel layer converted the same way, the converted rendering {baseline:.2}/255" + ); + } +} diff --git a/crates/engine/tests/unsupported_smart_filters.rs b/crates/engine/tests/unsupported_smart_filters.rs new file mode 100644 index 0000000000..8861fea4d0 --- /dev/null +++ b/crates/engine/tests/unsupported_smart_filters.rs @@ -0,0 +1,85 @@ +use photocraft_doc::{LayerContent, LayerId, SmartFilter}; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +const STORED: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn mockup() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("imported filter", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + sm.smart_filters.push(SmartFilter { + command: "psd.unsupportedFilter".into(), + params: json!({"name": "Sharpen", "filterID": 1399353968, "psd": ""}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn cache(s: &Session, id: LayerId) -> photocraft_raster::Surface { + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a smart object") }; + sm.cache.clone().unwrap() +} + +fn png() -> Vec { + let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, vec![0; 8 * 8 * 3]).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +#[test] +fn geometry_edits_keep_the_stored_render_of_an_unsupported_filter() { + for (cmd, params) in [("edit.transform", json!({"scaleX": 50, "scaleY": 50})), ("image.imageSize", json!({"width": 20, "height": 15}))] { + let (mut s, id) = mockup(); + let before = cache(&s, id).content_bounds(); + s.execute(cmd, params).unwrap(); + let after = cache(&s, id); + let r = after.content_bounds(); + assert!(r.width() < before.width(), "{cmd} resized the stored render"); + assert_eq!(after.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "{cmd} kept the stored render"); + } +} + +#[test] +fn replacing_contents_reports_the_filters_it_cannot_apply() { + let (mut s, id) = mockup(); + let r = smart_cmds::replace_contents_bytes(&mut s, id, "new.png", png()).unwrap(); + let warnings = r["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Sharpen"), "{r}"); +} + +#[test] +fn supported_filters_still_re_render_after_a_transform() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("stale cache", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + let c = cache(&s, id); + let r = c.content_bounds(); + assert_ne!(c.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "a supported filter re-renders from the source"); +} diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 350486da28..5441a971ee 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -184,7 +184,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } if app.distort.liquify.is_some() { if enter(ctx) { - crate::liquify_ui::commit(app); + let _ = crate::liquify_ui::commit(app); } else if esc(ctx) { crate::liquify_ui::cancel(app); } else { diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 62eab8b49e..a70d6acd8e 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -966,6 +966,45 @@ fn replace_contents_picks_a_file_for_every_instance() { assert!(matches!(smart_source(&app, ids[1]), photocraft_doc::SmartSource::Embedded { ref file_name, .. } if file_name == "art.png")); } +/// Layer › Smart Objects › Relink to File… (and the layer context menu item) asks for a file and +/// links every instance to its path; it ran without a path before, so it always failed. +#[test] +fn relink_to_file_picks_a_file_for_every_instance() { + let (mut app, open, _, ids) = smart_app(); + let ctx = egui::Context::default(); + let before = ids.map(|id| smart_source(&app, id)); + // Cancelling changes nothing. + menus::invoke(&mut app, &ctx, "layer.smartObjects.relinkToFile", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, .. }, _)])); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(ids.map(|id| smart_source(&app, id)), before); + // The web picker answers only a name and bytes: nothing to link to, so the contents stay. + let png = photocraft_io::export(&app.session.active().unwrap().doc, "png", &Default::default()).unwrap().bytes; + menus::invoke(&mut app, &ctx, "layer.smartObjects.relinkToFile", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("art.png".into(), png.clone()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert_eq!(ids.map(|id| smart_source(&app, id)), before); + // A file picked on disk links both instances to its path. + let dir = std::env::temp_dir().join(format!("pcraft-relink-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("art.png"); + std::fs::write(&path, &png).unwrap(); + let path = path.to_string_lossy().into_owned(); + menus::invoke(&mut app, &ctx, "layer.smartObjects.relinkToFile", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Paths(vec![path.clone()]))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + std::fs::remove_dir_all(&dir).ok(); + for id in ids { + assert_eq!(smart_source(&app, id), photocraft_doc::SmartSource::Linked { path: path.clone() }); + } +} + #[test] fn export_contents_suggests_the_contents_file_name() { let (mut app, open, written, _) = smart_app(); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 4c6813b58c..95ce581083 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -14,6 +14,8 @@ use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::theme::Tokens; +mod zoom_center; + #[derive(Clone, Debug, PartialEq)] pub enum Kind { Range { @@ -119,8 +121,8 @@ pub fn parse_spec(spec: &str) -> Vec { Kind::Grid(n) } else if let Some((range, default)) = v.split_once('=').map(|(a, b)| (a, b.trim())).or(Some((v, ""))) { if let Some((lo, hi)) = range.split_once("..") { - let min = lo.trim().parse().unwrap_or(0.0); - let max = hi.trim().parse().unwrap_or(100.0); + let min = leading_number(lo).unwrap_or(0.0); + let max = leading_number(hi).unwrap_or(100.0); let default = default.parse().unwrap_or(min); Kind::Range { min, max, default } } else { @@ -134,6 +136,17 @@ pub fn parse_spec(spec: &str) -> Vec { out } +/// The number a range bound starts with, ignoring a unit or marker after it: `5000 px?` → 5000, +/// `1000 %` → 1000. A bound that is not a number at all is `None`. +fn leading_number(bound: &str) -> Option { + let b = bound.trim(); + if let Ok(v) = b.parse() { + return Some(v); + } + let end = b.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+'))).unwrap_or(b.len()); + b.get(..end)?.parse().ok() +} + /// The inside of the spec's leading `{…}` object. Notes after it, like `→ {…}` results or /// `(per-range [c,m,y,k] arrays: …)`, are documentation: read as parameters, their commas split /// off junk fields and glued the note onto the last parameter (Selective Color's `"reds":json` @@ -775,6 +788,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { crate::select_mask_view::controls(ui, f); } for p in parse_spec(&spec) { + if zoom_center::owns(&cmd, f) && matches!(p.key.as_str(), "centerX" | "centerY") { + if p.key == "centerX" { + zoom_center::row(ui, f); + } + continue; + } match p.kind { Kind::Range { min, max, default } => { let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; @@ -1236,6 +1255,28 @@ impl FilterPreviewKey { mod tests { use super::*; + #[test] + fn a_unit_after_a_range_bound_does_not_reset_the_range_to_its_fallback() { + // #7598: `0.5..5000 px?` fell back to a maximum of 100, so the Mixer Brush's Size slider + // (and every range documented with a px or % suffix) topped out at 100. + let range = |spec: &str| match parse_spec(spec).into_iter().next().map(|p| p.kind) { + Some(Kind::Range { min, max, default }) => (min, max, default), + other => panic!("{spec}: {other:?}"), + }; + assert_eq!(range(r#"{"size":0.5..5000 px?}"#), (0.5, 5000.0, 0.5)); + assert_eq!(range(r#"{"scale":1..1000 %=100}"#), (1.0, 1000.0, 100.0)); + assert_eq!(range(r#"{"tolerance":0.5..10 px=2}"#), (0.5, 10.0, 2.0)); + assert_eq!(range(r#"{"angle":-180..180}"#), (-180.0, 180.0, -180.0)); + assert_eq!(range(r#"{"radius":0.1..1000=1}"#), (0.1, 1000.0, 1.0)); + assert_eq!(range(r#"{"gain":1e-3..2e2}"#), (0.001, 200.0, 0.001)); + // A bound that is no number at all still takes the old fallback rather than failing. + assert_eq!(range(r#"{"x":0..many}"#), (0.0, 100.0, 0.0)); + // The documented specs of the real commands read their full ranges. + let spec = photocraft_engine::commands::find("paint.pencil").expect("paint.pencil is registered"); + let size = parse_spec(spec.params).into_iter().find(|p| p.key == "size"); + assert!(matches!(size.map(|p| p.kind), Some(Kind::Range { max, .. }) if max == 5000.0)); + } + #[test] fn color_settings_working_spaces_list_installed_profiles_of_their_space() { // #6323: a press condition installed in the system can be the CMYK working space, as in diff --git a/crates/ui-egui/src/filter_dialog/zoom_center.rs b/crates/ui-egui/src/filter_dialog/zoom_center.rs new file mode 100644 index 0000000000..3acb09b052 --- /dev/null +++ b/crates/ui-egui/src/filter_dialog/zoom_center.rs @@ -0,0 +1,101 @@ +//! Radial Blur's Zoom centre: a schematic of the streaks beside the two coordinate sliders. +//! The diagram and sliders edit the same dialog fields used by preview, OK and automation. + +use egui::{Rect, Response, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use serde_json::{Map, Value, json}; + +use crate::theme::Tokens; + +pub(super) fn owns(command: &str, fields: &Map) -> bool { + command == "filter.blur.radialBlur" && fields.get("method").and_then(Value::as_str) == Some("zoom") +} + +fn number(fields: &Map, key: &str, default: f64, min: f64, max: f64) -> f32 { + fields.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(default).clamp(min, max) as f32 +} + +fn rounded(value: f32) -> f32 { + (value.clamp(0.0, 1.0) * 100.0).round() / 100.0 +} + +pub(super) fn row(ui: &mut Ui, fields: &mut Map) { + let mut center = vec2(number(fields, "centerX", 0.5, 0.0, 1.0), number(fields, "centerY", 0.5, 0.0, 1.0)); + let amount = number(fields, "amount", 10.0, 1.0, 100.0); + ui.horizontal(|ui| { + let (rect, mut response) = ui.allocate_exact_size(vec2(112.0, 112.0), Sense::click_and_drag()); + // Leave room for the centre marker at 0 and 1, including all four corners. + let plot = rect.shrink(4.0); + interact(ui, &mut response, plot, &mut center); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + for (key, value) in [("centerX", &mut center.x), ("centerY", &mut center.y)] { + let label = super::label(key); + let gestures = crate::widgets::RowGestures { reset: Some(0.5), wheel_step: None, ..Default::default() }; + let slider = crate::widgets::slider_row_with(ui, &label, value, 0.0..=1.0, "", None, gestures); + *value = rounded(*value); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), &label)); + } + }); + // Paint after both sliders so their edits move the marker in this very frame. + paint(ui, rect, plot, center, amount, response.has_focus()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Center"))); + response.on_hover_cursor(egui::CursorIcon::Crosshair); + }); + for (key, value) in [("centerX", center.x), ("centerY", center.y)] { + fields.insert(key.into(), json!((f64::from(value) * 100.0).round() / 100.0)); + } +} + +fn interact(ui: &Ui, response: &mut Response, plot: Rect, center: &mut Vec2) { + ui.memory_mut(|m| m.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })); + let primary = egui::PointerButton::Primary; + if (response.clicked_by(primary) || response.dragged_by(primary) || (response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()))) + && let Some(p) = response.interact_pointer_pos().filter(|p| p.x.is_finite() && p.y.is_finite()) + { + *center = vec2(rounded((p.x - plot.left()) / plot.width()), rounded((p.y - plot.top()) / plot.height())); + response.request_focus(); + response.mark_changed(); + } + if response.has_focus() { + let step = ui.input(|i| { + vec2( + i.num_presses(egui::Key::ArrowRight) as f32 - i.num_presses(egui::Key::ArrowLeft) as f32, + i.num_presses(egui::Key::ArrowDown) as f32 - i.num_presses(egui::Key::ArrowUp) as f32, + ) + }); + if step != Vec2::ZERO { + *center = vec2(rounded(center.x + step.x * 0.01), rounded(center.y + step.y * 0.01)); + response.mark_changed(); + } + } +} + +fn paint(ui: &Ui, rect: Rect, plot: Rect, center: Vec2, amount: f32, focused: bool) { + if !ui.is_rect_visible(rect) { + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, t.radius_sm, t.field); + let painter = ui.painter_at(rect.shrink(1.0)); + let origin = plot.min + center * plot.size(); + // A regular grid of points stretches away from the origin. The Zoom kernel samples + // inward by amount/200, so a source point's outermost image is at 1/(1 - amount/200). + // This small fixed diagram never filters or reads the document's pixels. + let stretch = 1.0 / (1.0 - amount / 200.0); + for y in 0..9 { + for x in 0..9 { + let point = plot.min + vec2((x as f32 + 0.5) / 9.0, (y as f32 + 0.5) / 9.0) * plot.size(); + let end = origin + (point - origin) * stretch; + for step in 0..4 { + let from = point.lerp(end, step as f32 / 4.0); + let to = point.lerp(end, (step + 1) as f32 / 4.0); + painter.line_segment([from, to], Stroke::new(1.0, t.text_dim.gamma_multiply(1.0 - step as f32 / 5.0))); + } + } + } + painter.line_segment([pos2(plot.left(), origin.y), pos2(plot.right(), origin.y)], Stroke::new(1.0, t.field_border)); + painter.line_segment([pos2(origin.x, plot.top()), pos2(origin.x, plot.bottom())], Stroke::new(1.0, t.field_border)); + painter.circle(origin, 3.0, t.field, Stroke::new(1.5, t.accent)); + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, if focused { t.accent } else { t.field_border }), egui::StrokeKind::Inside); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e1f044d87..0ed539ff28 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1105,7 +1105,7 @@ Done Hotovo Dots Tečky Drag a rectangle around the object Táhněte obdélník kolem objektu - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím odstraníte. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím na omezení ho změníte nebo odstraníte. Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: vybrat bod · Tažení: přesunout bod, úchyt nebo segment · {shift}+klik: přidat · {alt}+klik: celá podcesta Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 315f7ebb55..ef46d206be 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1088,7 +1088,7 @@ Done Fertig Dots Punkte Drag a rectangle around the object Ziehe ein Rechteck um das Objekt - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Ziehe auf dem Bild, um eine Zwangsbedingung hinzuzufügen; Shift für horizontal/vertikal; Rechtsklick löscht. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Ziehe auf dem Bild, um eine Zwangsbedingung hinzuzufügen; Shift für horizontal/vertikal; Rechtsklick auf eine Zwangsbedingung ändert oder löscht sie. Drag to move the active shape's path or the Work Path Ziehen, um den Pfad der aktiven Form oder den Arbeitspfad zu verschieben Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klick: Ankerpunkt auswählen · Ziehen: Ankerpunkt, Griff oder Segment verschieben · {shift}-Klick: hinzufügen · {alt}-Klick: ganzer Teilpfad Draw with the Pen tool (P) or make a work path from a selection. Zeichne mit dem Stift-Werkzeug (P) oder erzeuge einen Arbeitspfad aus einer Auswahl. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c38c837480..dbde5991fd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2354,7 +2354,7 @@ Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Σύρετε για λάσο ή κάντε κλικ για πολυγωνική επιλογή · {add} προσθήκη · {sub} αφαίρεση Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Σύρετε για περιοχή περικοπής · σύρετε στο εσωτερικό για μετακίνηση · από τα άκρα αλλάζει μέγεθος ({ratio} αναλογία, {centre} κέντρο) · Space για μετακίνηση κατά τη σχεδίαση · βέλη για μετακίνηση · X εναλλαγή προσανατολισμού · {commit} εφαρμογή · Esc ακύρωση Drag around the centre to rotate the view · Shift constrains to 15° Σύρετε γύρω από το κέντρο για να περιστρέψετε την προβολή · Shift περιορίζει στις 15° - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Σύρετε πάνω στην εικόνα για προσθήκη περιορισμού· Shift για οριζόντιο/κατακόρυφο· δεξί κλικ για διαγραφή. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Σύρετε πάνω στην εικόνα για προσθήκη περιορισμού· Shift για οριζόντιο/κατακόρυφο· δεξί κλικ σε έναν περιορισμό για αλλαγή ή διαγραφή. Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Σύρετε ή κάντε κλικ για πολυγωνικά σημεία · κρατήστε Alt κατά το λάσο για ευθείες · {add} προσθήκη · {sub} αφαίρεση πριν από τη σχεδίαση Drag to draw a gradient Σύρετε για να σχεδιάσετε διαβάθμιση Drag to move the active layer Σύρετε για μετακίνηση του ενεργού επιπέδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 025eb3e195..9ecae1e3de 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1095,7 +1095,7 @@ Done Hecho Dots Puntos Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho para eliminar. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho en una restricción para cambiarla o eliminarla. Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el trazado de trabajo Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleccionar punto · Arrastrar: mover punto, manejador o segmento · {shift}-clic: añadir · {alt}-clic: subtrazado completo Draw with the Pen tool (P) or make a work path from a selection. Dibuje con la herramienta Pluma (P) o cree un trazado de trabajo a partir de una selección. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index af00250e5e..d30ec76a3c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1080,7 +1080,7 @@ Done Terminé Dots Points Drag a rectangle around the object Tracez un rectangle autour de l'objet - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Faites glisser sur l'image pour ajouter une contrainte ; Maj pour horizontal/vertical ; clic droit pour supprimer. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Faites glisser sur l'image pour ajouter une contrainte ; Maj pour horizontal/vertical ; clic droit sur une contrainte pour la modifier ou la supprimer. Drag to move the active shape's path or the Work Path Faites glisser pour déplacer le tracé de la forme active ou le tracé de travail Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic : sélectionner le point · Glisser : déplacer point, poignée ou segment · {shift}-clic : ajouter · {alt}-clic : sous-tracé entier Draw with the Pen tool (P) or make a work path from a selection. Dessinez avec l'outil Plume (P) ou créez un tracé de travail à partir d'une sélection. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4dab4d6887..547fe82c15 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2078,7 +2078,7 @@ navigator Zoom Nagyítás mértéke Distortion Torzítás Dither Árnyalatkeverés Double Line Kettős vonal - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Húzással kényszert adhatsz a képhez; Shift: vízszintes/függőleges; jobb kattintás: törlés. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Húzással kényszert adhatsz a képhez; Shift: vízszintes/függőleges; jobb kattintás egy kényszeren: módosítás vagy törlés. Dry Brush Száraz ecset Dust & Scratches Por és karcolások Dynamic color sliders Dinamikus színcsúszkák diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f965b61949..5398e20147 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1090,7 +1090,7 @@ Done Selesai Dots Titik Drag a rectangle around the object Seret persegi panjang di sekeliling objek - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Seret pada gambar untuk menambah batasan; Shift untuk horizontal/vertikal; klik kanan untuk menghapus. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Seret pada gambar untuk menambah batasan; Shift untuk horizontal/vertikal; klik kanan pada batasan untuk mengubah atau menghapusnya. Drag to move the active shape's path or the Work Path Seret untuk memindah path bentuk aktif atau Work Path Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: pilih titik jangkar · Seret: pindahkan titik, gagang, atau segmen · {shift}-klik: tambah · {alt}-klik: seluruh subpath Draw with the Pen tool (P) or make a work path from a selection. Gambar dengan alat Pen (P) atau buat work path dari seleksi. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c38dbe36f6..49e314c945 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1090,7 +1090,7 @@ Done Fatto Dots Punti Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro per eliminare. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro su un vincolo per modificarlo o eliminarlo. Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 728f7b5c74..bff891a6d5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1093,7 +1093,7 @@ Done 完了 Dots ドット Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 画像上をドラッグして制約を追加。Shift で水平/垂直、右クリックで削除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 画像上をドラッグして制約を追加。Shift で水平/垂直、制約を右クリックで変更・削除。 Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath クリック: アンカーポイントを選択 · ドラッグ: ポイント、ハンドル、セグメントを移動 · {shift}+クリック: 追加 · {alt}+クリック: サブパス全体 Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b336b8a2b9..6b1b6a2aaf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1087,7 +1087,7 @@ Done 완료 Dots 점 Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 오른쪽 클릭하여 삭제. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 제약 조건을 오른쪽 클릭하여 변경 또는 삭제. Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 클릭: 기준점 선택 · 드래그: 기준점, 핸들 또는 세그먼트 이동 · {shift}-클릭: 추가 · {alt}-클릭: 하위 패스 전체 Draw with the Pen tool (P) or make a work path from a selection. 펜 도구(P)로 그리거나 선택 영역에서 작업 패스를 만드세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4e5cf70f35..ca12102cf6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1074,7 +1074,7 @@ Done Gereed Dots Stippen Drag a rectangle around the object Sleep een rechthoek rond het object - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Sleep over de afbeelding om een beperking toe te voegen; Shift voor horizontaal/verticaal; rechtsklik om te verwijderen. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Sleep over de afbeelding om een beperking toe te voegen; Shift voor horizontaal/verticaal; rechtsklik op een beperking om deze te wijzigen of te verwijderen. Drag to move the active shape's path or the Work Path Sleep om het pad van de actieve vorm of het werkpad te verplaatsen Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: ankerpunt selecteren · Slepen: ankerpunt, greep of segment verplaatsen · {shift}-klik: toevoegen · {alt}-klik: heel subpad Draw with the Pen tool (P) or make a work path from a selection. Teken met het pengereedschap (P) of maak een werkpad van een selectie. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae1824012a..164dde659c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1109,7 +1109,7 @@ Done Gotowe Dots Kropki Drag a rectangle around the object Przeciągnij prostokąt wokół obiektu - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Przeciągnij po obrazie, aby dodać ograniczenie; Shift — poziomo/pionowo; prawy przycisk usuwa. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Przeciągnij po obrazie, aby dodać ograniczenie; Shift — poziomo/pionowo; prawy przycisk na ograniczeniu — zmiana lub usunięcie. Drag to move the active shape's path or the Work Path Przeciągnij, aby przesunąć ścieżkę aktywnego kształtu lub ścieżkę roboczą Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: zaznacz punkt kontrolny · Przeciągnięcie: przesuń punkt, uchwyt lub segment · {shift}-klik: dodaj · {alt}-klik: cała podścieżka Draw with the Pen tool (P) or make a work path from a selection. Rysuj narzędziem Pióro (P) lub utwórz ścieżkę roboczą z zaznaczenia. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2557769480..3454ecd5ee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1075,7 +1075,7 @@ Done Concluído Dots Pontos Drag a rectangle around the object Arraste um retângulo ao redor do objeto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito para excluir. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito em uma restrição para alterá-la ou excluí-la. Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clique: selecionar ponto · Arrastar: mover ponto, alça ou segmento · {shift}-clique: adicionar · {alt}-clique: subdemarcador inteiro Draw with the Pen tool (P) or make a work path from a selection. Desenhe com a ferramenta Caneta (P) ou crie um demarcador de trabalho a partir de uma seleção. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 565d1ef8cb..cb81e204dd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1586,7 +1586,7 @@ Click to restore the current colour Щёлкните, чтобы восстановить текущий цвет Click with the Note tool to add a note. Щёлкните инструментом «Заметки», чтобы добавить заметку. Drag a rectangle around the object Обведите объект прямоугольником - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Перетащите по изображению, чтобы добавить ограничение; Shift — горизонталь/вертикаль; правый клик — удалить. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетащите по изображению, чтобы добавить ограничение; Shift — горизонталь/вертикаль; щелчок правой кнопкой по ограничению — изменить или удалить. Drag the arrow to set the angle, the dots to set the roundness Перетащите стрелку, чтобы задать угол, и точки — круглость Create new pattern from the selection Создать новый паттерн из выделения Create new gradient Создать новый градиент diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c93c45be31..f123d0c654 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1061,7 +1061,7 @@ Done Gotovo Dots Tačke Drag a rectangle around the object Prevucite pravougaonik oko objekta - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik za brisanje. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik na ograničenje za izmenu ili brisanje. Drag to move the active shape's path or the Work Path Prevucite da pomerite putanju aktivnog oblika ili radnu putanju Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: izaberi sidrišnu tačku · Prevlačenje: pomeri tačku, ručicu ili segment · {shift}-klik: dodaj · {alt}-klik: cela podputanja Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatkom pero (P) ili napravite radnu putanju od selekcije. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 106385d1b2..2a9bbe09da 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1055,6 +1055,7 @@ Dots Noktalar Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın. Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 46f1a3057f..ad53ce16b8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1563,7 +1563,7 @@ Click to restore the current colour Клацніть, щоб відновити поточний колір Click with the Note tool to add a note. Клацніть інструментом «Нотатка», щоб додати нотатку. Drag a rectangle around the object Намалюйте прямокутник навколо об'єкта - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Перетягніть на зображенні, щоб додати обмеження; Shift для горизонталі/вертикалі; праве клацання для видалення. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетягніть на зображенні, щоб додати обмеження; Shift для горизонталі/вертикалі; клацніть правою кнопкою на обмеженні, щоб змінити або видалити його. Drag the arrow to set the angle, the dots to set the roundness Перетягніть стрілку, щоб установити кут, а точки — округлість Create new pattern from the selection Створити новий візерунок із виділення Create new gradient Створити новий градієнт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2123ec228f..581d6700b2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2259,7 +2259,7 @@ layers Fill: Độ tô: Delete Timeline Xóa dòng thời gian Deselect All Bỏ chọn tất cả Display Text Hiển thị văn bản - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kéo trên ảnh để thêm ràng buộc; Shift cho ngang/dọc; nhấp chuột phải để xóa. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kéo trên ảnh để thêm ràng buộc; Shift cho ngang/dọc; nhấp chuột phải vào ràng buộc để thay đổi hoặc xóa. Export selected measurements (CSV) Xuất các phép đo đã chọn (CSV) Focal Length Tiêu cự Font Size: Cỡ chữ: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b47afe56f8..eeae5165b9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1115,7 +1115,7 @@ Done 完成 Dots 点 Drag a rectangle around the object 围绕对象拖出矩形 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击删除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击约束可更改或删除。 Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 单击:选择锚点 · 拖动:移动锚点、手柄或线段 · {shift}-单击:添加 · {alt}-单击:整个子路径 Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index db55fdf765..021219d8a2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1102,7 +1102,7 @@ Done 完成 Dots 點 Drag a rectangle around the object 在物件周圍拖曳出矩形 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;按右鍵可刪除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;在限制線上按右鍵可變更或刪除。 Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 按一下:選取錨點 · 拖曳:移動錨點、把手或線段 · {shift}-按一下:增加 · {alt}-按一下:整個子路徑 Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。 diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index beb3e9bc32..57cf1b5012 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -77,6 +77,8 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { LayerContent::Shape(_) => v.push(Some(("Rasterize Layer", "layer.rasterize.shape"))), LayerContent::Smart(_) => { v.push(Some((tl!("Edit Contents"), "layer.smartObjects.editContents"))); + v.push(Some((tl!("Replace Contents…"), "layer.smartObjects.replaceContents"))); + v.push(Some((tl!("Relink to File…"), "layer.smartObjects.relinkToFile"))); v.push(Some((tl!("Convert to Layers"), "layer.smartObjects.convertToLayers"))); v.push(Some(("Rasterize Layer", "layer.rasterize.smartObject"))); } @@ -574,7 +576,10 @@ mod tests { s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); let ids: Vec<_> = entries(&layer(&s), false, false).into_iter().flatten().map(|e| e.1).collect(); let edit = ids.iter().position(|id| *id == "layer.smartObjects.editContents").expect("Edit Contents entry"); - assert_eq!(ids[edit + 1..edit + 3], ["layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"]); + assert_eq!( + ids[edit + 1..edit + 5], + ["layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"] + ); } /// Right-clicking inside a multi-layer selection offers one Rasterize item for the whole @@ -592,7 +597,14 @@ mod tests { assert!(ids(false).contains(&"layer.smartObjects.editContents"), "a single smart object keeps its items"); let multi = ids(true); assert!(multi.contains(&"layer.rasterize.layer")); - for single in ["layer.rasterize.smartObject", "layer.rasterize.shape", "layer.smartObjects.editContents", "layer.smartObjects.convertToLayers"] { + for single in [ + "layer.rasterize.smartObject", + "layer.rasterize.shape", + "layer.smartObjects.editContents", + "layer.smartObjects.replaceContents", + "layer.smartObjects.relinkToFile", + "layer.smartObjects.convertToLayers", + ] { assert!(!multi.contains(&single), "{single} acts on one layer, not on a selection"); } let label = entries(&smart, true, false).into_iter().flatten().find(|e| e.1 == "layer.rasterize.layer").unwrap().0; diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 9f46020b11..cf1c932518 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -192,6 +192,8 @@ pub struct LiquifyDialog { pub dab_ms: f64, /// CPU time spent refreshing the zoomed-in source-resolution preview. pub preview_ms: f64, + /// The smart filter being edited: OK updates it instead of adding a new Liquify filter. + smart_filter: Option, } fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> { @@ -228,6 +230,7 @@ impl LiquifyDialog { "dabMs": self.dab_ms, "fullResolutionPreview": self.view_bounds.is_some(), "previewMs": self.preview_ms, + "smartFilter": self.smart_filter, }) } @@ -255,6 +258,8 @@ impl LiquifyDialog { /// Starts a stroke; `alt` draws with the selected tool's opposite (see [`alt_tool`]). fn begin(&mut self, p: [f64; 3], now: f64, alt: bool) { + // A stroke still open has already changed the field; record it so undo can replay it (#8055). + self.end(); self.redo.clear(); let tool = if alt { alt_tool(self.opts.tool) } else { self.opts.tool }; let mut s = self.template(tool); @@ -484,10 +489,42 @@ impl LiquifyDialog { /// Opens the dialog on the active layer. pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; + let canvas = app.session.active().ok_or("no document")?.doc.bounds(); + open_on(app, ctx, layer, surf, Vec::new(), auto_cell(canvas), None) +} + +/// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and +/// mesh size, previewing on the pixels below it; OK updates that filter +/// (`layer.smartFilter.setParams`). +pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> { + use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params}; + let st = app.session.active().ok_or("no document")?; + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { + return Err("the layer is not a smart object".into()); + }; + let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?; + if filter.command != LIQUIFY { + return Err("the smart filter is not a Liquify filter".into()); + } + let (strokes, cell) = liquify_params(LIQUIFY, &filter.params, st.doc.bounds()).map_err(|e| e.to_string())?; + let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) + .map_err(|e| e.to_string())? + .ok_or("the smart object's contents are unavailable (missing linked file?)")?; + open_on(app, ctx, layer, surf, strokes, cell, Some(index)) +} + +fn open_on( + app: &mut PhotocraftApp, + ctx: &egui::Context, + layer: LayerId, + surf: Surface, + strokes: Vec, + cell: f64, + smart_filter: Option, +) -> Result<(), String> { let st = app.session.active().ok_or("no document")?; let canvas = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let cell = auto_cell(canvas); let proxy = ProxyImage::new(&surf, canvas, PROXY_SIDE); let out = vec![[0u8; 4]; proxy.w * proxy.h]; let center = [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0]; @@ -496,8 +533,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> layer_name: name, canvas, cell, - field: LiquifyField::new(canvas, cell), - strokes: Vec::new(), + field: LiquifyField::from_strokes(canvas, cell, &strokes), + strokes, redo: Vec::new(), cur: None, lasso: None, @@ -522,6 +559,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> last_dab: 0.0, dab_ms: 0.0, preview_ms: 0.0, + smart_filter, }; // A sensible starting brush (about a tenth of the image), then the settings last used, so // Liquify opens as it was left (#418). Saved values are clamped; a bad one is skipped. @@ -550,22 +588,29 @@ pub fn cancel(app: &mut PhotocraftApp) { } } -/// OK: runs `filter.liquify` with the recorded strokes (one history step). -pub fn commit(app: &mut PhotocraftApp) { - let Some(mut d) = app.distort.liquify.take() else { return }; +/// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits +/// a smart filter, OK updates that filter instead (also when Restore All left no strokes). The +/// dialog closes either way. `app.run` shows a failed command's error in the status bar, and +/// `commit` returns it. +pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> { + let Some(mut d) = app.distort.liquify.take() else { return Ok(()) }; remember(app, &d.opts); d.end(); + if let Some(index) = d.smart_filter { + let params = json!({"strokes": d.strokes, "meshSize": d.cell}); + return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})).map(|_| ()); + } if d.strokes.is_empty() { - return; + return Ok(()); } let p = json!({"strokes": d.strokes, "meshSize": d.cell, "layer": d.layer.0}); - let _ = app.run("filter.liquify", p); + app.run("filter.liquify", p).map(|_| ()) } /// Control channel: `filter.liquify {"ui": {...}}` while the dialog is open. pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if ui.get("commit").and_then(Value::as_bool) == Some(true) { - commit(app); + commit(app)?; return Ok(json!({"committed": true})); } if ui.get("cancel").and_then(Value::as_bool) == Some(true) { @@ -616,6 +661,13 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { } } +/// Where the brush outline is drawn: under the pointer, except during an Alt-drag of the brush +/// size (`resize`), when it stays at the drag's anchor and only grows or shrinks, as the brush +/// tools' Alt-drag does outside Liquify (#7884). +fn brush_circle_at(resize: Option<(Pos2, f32, f32)>, hover: Option) -> Option { + resize.map(|(anchor, ..)| anchor).or(hover) +} + /// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { mods.alt && (secondary || mods.ctrl) @@ -683,7 +735,18 @@ pub fn history_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { history_keys(app, ctx); + // ⌘+ / ⌘− / ⌘0 / ⌘1 (or their overrides) zoom the preview, not the canvas behind it (#7210). + let nav = if app.distort.liquify.is_some() { crate::shortcuts::nav_shortcut(app, ctx) } else { None }; let Some(d) = app.distort.liquify.as_mut() else { return }; + if let Some(id) = nav { + d.zoom = match id { + "view.zoomIn" => crate::zoom_levels::step_in_range(d.zoom, 1, ZOOM_MIN, ZOOM_MAX), + "view.zoomOut" => crate::zoom_levels::step_in_range(d.zoom, -1, ZOOM_MIN, ZOOM_MAX), + // Fit on Screen: the preview fits and centres the image on its next frame. + "view.fitOnScreen" => 0.0, + _ => 1.0, + }; + } // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -836,6 +899,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut action: Option<&str> = None; let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; + let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; egui::Area::new(egui::Id::new("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.liquify.as_mut() else { return }; d.upload(ctx); @@ -963,19 +1027,29 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { painter.rect_filled(area, 0.0, t.canvas); let (cw, ch) = (d.canvas.width() as f32, d.canvas.height() as f32); if d.zoom <= 0.0 { - d.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).clamp(0.01, 4.0); + d.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).clamp(ZOOM_MIN, 4.0); d.center = [f64::from(d.canvas.x0) + f64::from(cw) / 2.0, f64::from(d.canvas.y0) + f64::from(ch) / 2.0]; } let resp = ui.interact(area, egui::Id::new("liquify-preview"), Sense::click_and_drag()); - // Wheel zooms about the pointer. - if resp.hovered() { - let scroll = ui.input(|i| i.smooth_scroll_delta.y); - if scroll != 0.0 - && let Some(hp) = resp.hover_pos() - { + // The wheel zooms about the pointer, ×1.1 a notch, and so does a trackpad pinch (unless + // Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off); a sideways scroll + // pans (#7210). Read every frame, as `wheel_nav` asks, so its modifier state follows. + let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, egui::Id::new("liquify-preview-wheel"), resp.hovered()); + if let Some(hp) = resp.hover_pos() { + let zoom = match wheel { + Some(crate::wheel_nav::Wheel::Zoom(f)) => Some((d.zoom * f).clamp(ZOOM_MIN, ZOOM_MAX)), + Some(crate::wheel_nav::Wheel::SnapZoom(n)) => { + Some((0..n.unsigned_abs()).fold(d.zoom, |z, _| crate::zoom_levels::step_in_range(z, n.signum(), ZOOM_MIN, ZOOM_MAX))) + } + Some(crate::wheel_nav::Wheel::Pan(v)) => { + d.center = [d.center[0] - f64::from(v.x / d.zoom), d.center[1] - f64::from(v.y / d.zoom)]; + None + } + None => None, + }; + if let Some(z) = zoom { let before = to_doc(d, area, hp); - // Liquify's own preview zoom, not the canvas (`zoom_levels` doesn't apply). - d.zoom = (d.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + d.zoom = z; let after = to_doc(d, area, hp); d.center = [d.center[0] + before[0] - after[0], d.center[1] + before[1] - after[1]]; } @@ -1036,8 +1110,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let line: Vec = pts.iter().map(|p| to_screen(d, area, *p)).collect(); clip.add(egui::Shape::line(line, Stroke::new(1.0, color))); } - // Brush outline (the lasso has none). - if let Some(hp) = resp.hover_pos() { + // Brush outline (the lasso has none). While Alt-dragging its size it stays where the drag + // started, so only its size changes (#7884). + if let Some(hp) = brush_circle_at(d.brush_resize, resp.hover_pos()) { if d.opts.tool != LiquifyTool::LassoMask { let r = d.opts.size * 0.5 * d.zoom; clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); @@ -1049,10 +1124,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Mouse → strokes (Space or middle button pans). Alt+right-drag resizes the brush (#4702). let (pan, secondary, mods_now) = ui.input(|i| (i.key_down(egui::Key::Space) || i.pointer.middle_down(), i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); - let resize = d.brush_resize.is_some() || (resp.drag_started() && brush_resize_gesture(mods_now, secondary)); + // From the press, not once the drag is recognised a few points later: that first frame + // would otherwise start a stroke. + let resize = d.brush_resize.is_some() || (resp.is_pointer_button_down_on() && brush_resize_gesture(mods_now, secondary)); if resize { + // Anchored where the button went down, not where the drag was recognised a few + // points later, so the outline doesn't jump when the gesture starts. if d.brush_resize.is_none() - && let Some(p) = resp.interact_pointer_pos() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { d.brush_resize = Some((p, d.opts.size, d.opts.density)); } @@ -1078,7 +1157,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: pr }); ctx.request_repaint(); } - } else if resp.drag_started() || (resp.is_pointer_button_down_on() && d.cur.is_none()) { + } else if (resp.drag_started() || resp.is_pointer_button_down_on()) && d.cur.is_none() { + // The stroke already began at the press; egui's later drag start must not restart it (#8055). events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: pr }); } else if resp.dragged() || resp.is_pointer_button_down_on() { let moved = d.cur.as_ref().is_some_and(|(_, l)| l[0] != q[0] || l[1] != q[1]); @@ -1101,12 +1181,19 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { pointer(app, ev, mods); } match action { - Some("ok") => commit(app), + Some("ok") => { + let _ = commit(app); + } Some("cancel") => cancel(app), _ => {} } } +/// The preview's zoom range (1 = the dialog's 100 %). Liquify keeps its own zoom, apart from the +/// canvas behind it. +const ZOOM_MIN: f32 = 0.01; +const ZOOM_MAX: f32 = 32.0; + fn to_screen(d: &LiquifyDialog, area: ERect, p: [f64; 2]) -> Pos2 { area.center() + vec2(((p[0] - d.center[0]) as f32) * d.zoom, ((p[1] - d.center[1]) as f32) * d.zoom) } @@ -1171,6 +1258,15 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { mod tests { use super::*; + #[test] + fn the_brush_outline_follows_the_pointer_except_while_resizing() { + let (hover, anchor) = (pos2(30.0, 40.0), pos2(10.0, 20.0)); + assert_eq!(brush_circle_at(None, Some(hover)), Some(hover)); + assert_eq!(brush_circle_at(Some((anchor, 40.0, 50.0)), Some(hover)), Some(anchor)); + assert_eq!(brush_circle_at(Some((anchor, 40.0, 50.0)), None), Some(anchor)); + assert_eq!(brush_circle_at(None, None), None); + } + #[test] fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { assert!(brush_resize_gesture(egui::Modifiers::ALT, true)); @@ -1297,7 +1393,7 @@ mod tests { }); // OK and Esc keep the latest settings too. control(&mut app, &json!({"density": 90})).unwrap(); - commit(&mut app); + commit(&mut app).unwrap(); open(&mut app, &ctx).unwrap(); assert_eq!(app.distort.liquify.as_ref().unwrap().opts.density, 90.0); control(&mut app, &json!({"density": 70})).unwrap(); @@ -1547,6 +1643,47 @@ mod tests { assert_eq!(d.redo.len(), REDO_STACK_LIMIT); } + /// #7884: Alt+right-dragging the brush size in the Liquify dialog keeps the brush where the + /// drag started (its outline and the point it would work at) and only changes its size, as the + /// brush tools' Alt-drag does outside Liquify. + #[test] + fn alt_drag_resize_keeps_the_brush_where_it_started() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let screen = ERect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 900.0)); + let frame = |app: &mut PhotocraftApp, events: Vec, mods: egui::Modifiers| { + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(raw, |ui| show(app, ui.ctx())).textures_delta.clear(); + }; + let alt = egui::Modifiers::ALT; + let none = egui::Modifiers::NONE; + frame(&mut app, vec![], none); + // A point over the image in the dialog's canvas (120 × 80): the first one on a grid. + let p = (1..22) + .flat_map(|j| (1..34).map(move |i| pos2(40.0 * i as f32, 40.0 * j as f32))) + .find(|&q| { + frame(&mut app, vec![egui::Event::PointerMoved(q)], none); + let h = app.distort.liquify.as_ref().and_then(|d| d.hover); + h.is_some_and(|h| (10.0..60.0).contains(&h[0]) && (20.0..60.0).contains(&h[1])) + }) + .expect("the dialog shows the image"); + frame(&mut app, vec![egui::Event::PointerMoved(p)], none); + let d = app.distort.liquify.as_ref().unwrap(); + let (start, size0) = (d.hover.expect("the pointer is over the canvas"), d.opts.size); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: alt }; + frame(&mut app, vec![button(true)], alt); + for k in 1..=6 { + frame(&mut app, vec![egui::Event::PointerMoved(p + vec2(10.0 * k as f32, 0.0))], alt); + } + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.opts.size > size0, "the drag resized the brush: {} -> {}", size0, d.opts.size); + assert_eq!(d.hover, Some(start), "the brush stayed where the drag started"); + assert!(d.strokes.is_empty(), "and nothing was painted"); + } + #[test] fn shortcut_handler_undoes_liquify_even_when_egui_owns_keyboard_focus() { let ctx = egui::Context::default(); @@ -1586,6 +1723,160 @@ mod tests { assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1, "Cmd+Shift+Z redoes the last stroke"); } + fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND } + } + + /// A frame of the open dialog in a 1000 × 700 window with `events`; returns the preview's + /// centre (between the tool strip and the options panel, under the title strip). + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec) -> Pos2 { + let screen = ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + pos2((LEFT_W + screen.width() - RIGHT_W) / 2.0, (30.0 + screen.height()) / 2.0) + } + + /// #7210: ⌘+ / ⌘− step Liquify's preview along the zoom ladder, ⌘0 fits it and ⌘1 is the + /// dialog's 100 %, through the canvas's own shortcuts; the canvas behind keeps its zoom. + #[test] + fn zoom_shortcuts_zoom_the_preview_not_the_canvas() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + frame(&mut app, &ctx, Vec::new()); + let fitted = app.distort.liquify.as_ref().unwrap().zoom; + assert!(fitted > 0.0, "the preview fits on its first frame"); + let canvas_zoom = app.ui.views.first().map(|v| v.zoom); + let mut press = |k: egui::Key| { + let raw = egui::RawInput { events: vec![key(k)], ..Default::default() }; + let _ = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + app.distort.liquify.as_ref().unwrap().zoom + }; + assert_eq!(press(egui::Key::Num1), 1.0); + assert_eq!(press(egui::Key::Equals), 2.0, "next step up the ladder"); + assert_eq!(press(egui::Key::Plus), 3.0, "⌘+ typed as Plus too"); + assert_eq!(press(egui::Key::Minus), 2.0); + assert_eq!(press(egui::Key::Num0), 0.0, "fit on the next frame"); + frame(&mut app, &ctx, Vec::new()); + assert_eq!(app.distort.liquify.as_ref().unwrap().zoom, fitted); + assert_eq!(app.ui.views.first().map(|v| v.zoom), canvas_zoom, "the canvas behind keeps its zoom"); + } + + /// #7210: a trackpad pinch over the preview zooms it about the pointer (macOS sends no wheel + /// for it), unless Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off; a wheel + /// notch is one ×1.1 step. + #[test] + fn pinch_and_wheel_zoom_the_preview_about_the_pointer() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let center = frame(&mut app, &ctx, Vec::new()); + let at = center + vec2(60.0, -40.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let screen = ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let area = ERect::from_min_max(pos2(LEFT_W, 30.0), pos2(screen.width() - RIGHT_W, screen.height())); + let (z0, under) = { + let d = app.distort.liquify.as_ref().unwrap(); + (d.zoom, to_doc(d, area, at)) + }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), egui::Event::Zoom(2.0)]); + let d = app.distort.liquify.as_ref().unwrap(); + assert!((d.zoom - z0 * 2.0).abs() < 1e-4, "{} → {}", z0, d.zoom); + let still = to_doc(d, area, at); + assert!((still[0] - under[0]).abs() < 1e-3 && (still[1] - under[1]).abs() < 1e-3, "the point under the pointer stays: {under:?} → {still:?}"); + + let notch = ctx.options(|o| o.input_options.line_scroll_speed); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }; + let z1 = app.distort.liquify.as_ref().unwrap().zoom; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), wheel]); + let z2 = app.distort.liquify.as_ref().unwrap().zoom; + assert!(notch > 0.0 && (z2 / z1 - crate::wheel_nav::NOTCH).abs() < 1e-3, "one notch is ×1.1: {z1} → {z2}"); + + app.session.prefs.edit(|p| p.enhanced_controls.zoom_with_trackpad_pinch = false); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), egui::Event::Zoom(2.0)]); + assert_eq!(app.distort.liquify.as_ref().unwrap().zoom, z2, "a pinch does nothing with the preference off"); + } + + /// The proxy rendered from the recorded strokes, i.e. what undo/replay rebuilds. + fn replayed_out(d: &LiquifyDialog) -> Vec<[u8; 4]> { + let field = LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes); + let mut out = vec![[0u8; 4]; d.proxy.w * d.proxy.h]; + d.proxy.render(&field, [0, 0, d.proxy.w, d.proxy.h], &mut out); + out + } + + /// #8055: a second pointer-down while a stroke is open must not drop the dabs already applied. + #[test] + fn second_down_without_up_keeps_the_open_strokes_dabs_recorded() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 30})).unwrap(); + for ev in [ + ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, + ToolEvent::Move { x: 46.0, y: 41.0, pressure: 1.0 }, + ToolEvent::Down { x: 46.0, y: 41.0, pressure: 1.0 }, + ToolEvent::Move { x: 55.0, y: 42.0, pressure: 1.0 }, + ToolEvent::Up { x: 55.0, y: 42.0 }, + ] { + pointer(&mut app, ev, egui::Modifiers::NONE); + } + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.out == replayed_out(d), "the live field must match the field rebuilt from the recorded strokes"); + } + + /// #8055: short strokes drawn with the mouse (moves inside egui's drag threshold first) are + /// recorded whole, as one stroke each, so undo removes only the last one. + #[test] + fn short_mouse_strokes_are_recorded_whole_and_undo_removes_only_the_last() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 30})).unwrap(); + let screen = ERect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0)); + let frame = |app: &mut PhotocraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let mut before_last = Vec::new(); + let strokes = 6; + for i in 0..strokes { + let start = pos2(560.0 + i as f32 * 25.0, 380.0 + (i % 2) as f32 * 20.0); + before_last = app.distort.liquify.as_ref().unwrap().out.clone(); + frame(&mut app, vec![egui::Event::PointerMoved(start)]); + let press = egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![press]); + let mut p = start; + for _ in 0..8 { + p += vec2(1.5, 1.0); + frame(&mut app, vec![egui::Event::PointerMoved(p)]); + } + let release = egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![release]); + frame(&mut app, vec![]); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), i + 1, "one recorded stroke per mouse stroke"); + assert!(d.out == replayed_out(d), "stroke {i}: the live field must match the recorded strokes"); + } + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), strokes - 1); + let differ = d.out.iter().zip(&before_last).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "undo restores the pixels from before the last stroke"); + } + #[test] fn menu_opens_the_dialog_and_routes_pointer_events() { let ctx = egui::Context::default(); @@ -1599,4 +1890,136 @@ mod tests { assert!(app.distort.liquify.is_none()); assert_eq!(app.session.active().unwrap().history.past_len(), 2, "cancel records nothing"); } + + /// A smart object with a Liquify smart filter (one stroke, mesh size 3) and a Gaussian Blur + /// above it. + fn smart_liquify_app() -> (PhotocraftApp, LayerId) { + let mut app = app_with_layer(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let stroke = json!({"tool": "forwardWarp", "size": 40, "points": [[40.0, 40.0, 1.0], [48.0, 41.0, 1.0], [56.0, 40.0, 1.0]]}); + app.run("filter.liquify", json!({"strokes": [stroke], "meshSize": 3.0})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2.0})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + (app, layer) + } + + fn smart(app: &PhotocraftApp, layer: LayerId) -> photocraft_doc::SmartObject { + match &app.session.active().unwrap().doc.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.clone(), + _ => panic!("not a smart object"), + } + } + + fn drag(app: &mut PhotocraftApp, from: f64, to: f64) { + for ev in [ToolEvent::Down { x: from, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: to, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: to, y: 40.0 }] { + pointer(app, ev, egui::Modifiers::NONE); + } + } + + #[test] + fn smart_filter_double_click_edits_the_recorded_liquify() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + let steps = app.session.active().unwrap().history.past_len(); + let sm = smart(&app, layer); + let recorded: Vec = serde_json::from_value(sm.smart_filters[0].params["strokes"].clone()).unwrap(); + assert_eq!(recorded.len(), 1); + + crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); + assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); + let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens"); + assert_eq!((d.smart_filter, d.cell), (Some(0), 3.0)); + assert_eq!(d.strokes, recorded); + assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded)); + // The preview starts from the pixels below the filter (no liquify, no blur). + let below = photocraft_engine::smart_cmds::render_below_filter(&app.session.active().unwrap().doc, &sm, 0).unwrap().unwrap(); + assert_eq!(d.source.read_region(d.canvas), below.read_region(d.canvas)); + + drag(&mut app, 70.0, 84.0); + control(&mut app, &json!({"commit": true})).unwrap(); + assert!(app.distort.liquify.is_none()); + let sm = smart(&app, layer); + assert_eq!(sm.smart_filters.len(), 2, "edited in place, not added"); + assert_eq!(sm.smart_filters[0].command, photocraft_engine::distort_cmds::LIQUIFY); + assert_eq!(sm.smart_filters[0].params["strokes"].as_array().map(Vec::len), Some(2)); + assert_eq!(sm.smart_filters[0].params["meshSize"], json!(3.0)); + assert_eq!(sm.smart_filters[1].command, "filter.blur.gaussianBlur"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].params["strokes"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn smart_filter_cancel_keeps_and_restore_all_clears_the_recorded_strokes() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + let steps = app.session.active().unwrap().history.past_len(); + let params = smart(&app, layer).smart_filters[0].params.clone(); + + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + drag(&mut app, 70.0, 84.0); + control(&mut app, &json!({"cancel": true})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].params, params, "Cancel leaves the filter as it was"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // OK after Restore All keeps the filter, with no strokes. + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + control(&mut app, &json!({"restoreAll": true})).unwrap(); + control(&mut app, &json!({"commit": true})).unwrap(); + let sm = smart(&app, layer); + assert_eq!(sm.smart_filters.len(), 2); + assert_eq!(sm.smart_filters[0].params["strokes"], json!([])); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + #[test] + fn open_smart_filter_rejects_other_filters_and_layers() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + assert!(open_smart_filter(&mut app, &ctx, layer, 1).is_err(), "the Gaussian Blur"); + assert!(open_smart_filter(&mut app, &ctx, layer, 9).is_err(), "no such filter"); + let mut plain = app_with_layer(); + let pixels = plain.session.active().unwrap().active_layer.unwrap(); + assert!(open_smart_filter(&mut plain, &ctx, pixels, 0).is_err(), "not a smart object"); + assert!(app.distort.liquify.is_none() && plain.distort.liquify.is_none()); + } + + /// OK on a smart filter that is gone: the workspace closes, the status bar shows the error and + /// the control channel returns it. + #[test] + fn smart_filter_edit_that_cannot_be_applied_reports_the_error() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + drag(&mut app, 70.0, 84.0); + app.session.execute("layer.smartFilter.clearSmartFilters", json!({"layer": layer.0})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let r = control(&mut app, &json!({"commit": true})); + assert!(app.distort.liquify.is_none()); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("no smart filters"), "{}", app.ui.status); + assert!(smart(&app, layer).smart_filters.is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!(r, Err(app.ui.status.clone())); + } + + /// OK after the layer was deleted: the dialog closes, the status bar shows the error and the + /// control channel returns it. + #[test] + fn liquify_that_cannot_be_applied_reports_the_error() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + open(&mut app, &ctx).unwrap(); + drag(&mut app, 40.0, 56.0); + assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1); + app.session.execute("layer.delete", json!({"layer": layer.0})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let r = control(&mut app, &json!({"commit": true})); + assert!(app.distort.liquify.is_none()); + assert!(app.ui.status_error); + assert!(!app.ui.status.is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!(r, Err(app.ui.status.clone())); + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 554bfb870c..bab926d9f1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2868,6 +2868,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { actions.push(done); } + // Likewise a selected smart filter whose row is gone. + crate::smart_ui::forget_hidden(ui.ctx(), doc.id); }); // Leaving the list or hovering an invalid target immediately cancels the temporary // image. The release still uses the actual command, not the preview snapshot. diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index ffc1a7f6e5..0f47282213 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -274,7 +274,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc /// selection is filled with the background colour in the mask (Photoshop's Delete on a mask), and /// with no selection the mask itself is deleted, as Layer › Layer Mask › Delete, unless several /// layers are selected: then they are deleted. -fn delete_key_command(app: &PhotocraftApp, id: String) -> String { +pub(crate) fn delete_key_command(app: &PhotocraftApp, id: String) -> String { if id != "edit.clear" { return id; } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e0ab91b601..9142e71804 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -196,18 +196,23 @@ fn new_layer_without_dialog(app: &mut PhotocraftApp, ctx: &egui::Context) -> boo } fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - for id in NAV_COMMANDS { - let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| app.session.prefs().shortcut(id, c.3)); + let Some(id) = nav_shortcut(app, ctx) else { return false }; + if crate::menus::is_enabled(app, id) { + let _ = crate::menus::invoke(app, ctx, id, json!({})); + } + true +} + +/// The [`NAV_COMMANDS`] entry whose shortcut (or an Edit › Keyboard Shortcuts override) was +/// pressed this frame, with the key press consumed. A view with its own zoom, like Liquify's +/// preview, steps it with the same keys (#7210). +pub(crate) fn nav_shortcut(app: &PhotocraftApp, ctx: &egui::Context) -> Option<&'static str> { + NAV_COMMANDS.into_iter().find(|id| { + let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == *id).and_then(|c| app.session.prefs().shortcut(id, c.3)); let keys: Vec = main.into_iter().map(str::to_string).chain(app.session.prefs().alternates(id, main)).filter_map(|k| parse(&k)).collect(); - if keys.iter().any(|sc| consume(ctx, sc)) { - if crate::menus::is_enabled(app, id) { - let _ = crate::menus::invoke(app, ctx, id, json!({})); - } - return true; - } - } - false + keys.iter().any(|sc| consume(ctx, sc)) + }) } /// The key press a clipboard event stands for outside text fields, with `mods` held: Copy, Cut @@ -606,6 +611,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::slice_ui::keys(app, ctx) { return; } + // A selected smart filter row: ⌫/Delete removes the filter, not its smart object (#4751). + if crate::smart_ui::keys(app, ctx) { + return; + } // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the // Hand key is held, before F's own Cycle Screen Mode. if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs index 9db54bbe1e..d05e02e014 100644 --- a/crates/ui-egui/src/smart_filter_rows_tests.rs +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -229,3 +229,91 @@ fn clicking_the_smart_object_row_deselects_its_filter() { click_at(&mut h, trash); assert_eq!(filters(&h, a).len(), 2, "the filters stay"); } + +/// #4751: Delete or Backspace removes the selected filter, as the Delete button does, not the +/// smart object; on the Smart Filters row it clears them all. +#[test] +fn the_delete_key_removes_the_selected_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a), ["filter.other.offset"]); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); + let row = at(&h, "Smart Filters"); + click_at(&mut h, row); + h.key_press(egui::Key::Backspace); + h.run_steps(4); + assert!(filters(&h, a).is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); +} + +/// A text field keeps its Delete: renaming the smart object leaves the selected filter alone. +#[test] +fn the_delete_key_in_a_text_field_leaves_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + let ctx = h.ctx.clone(); + crate::layer_row_ui::start_rename(&ctx, a, "Smart A"); + h.run_steps(4); + assert!(selected(&h, "Gaussian Blur"), "the filter is still selected while renaming"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a).len(), 2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4); +} + +/// Quick Mask keeps Delete for Edit › Clear on the mask, even with a filter's row selected. +#[test] +fn the_delete_key_in_quick_mask_clears_the_mask_not_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + // No selection: Quick Mask alone must keep the key, not a marquee's Edit › Clear. + h.state_mut().run("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + h.run_steps(4); + assert!(selected(&h, "Gaussian Blur"), "the filter is still selected in Quick Mask"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a).len(), 2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4); +} + +/// A filter whose row isn't drawn (here the panel is filtered by name) is no longer selected: +/// Delete then deletes the active layer as usual, never a filter nobody sees highlighted. +#[test] +fn a_hidden_filter_row_is_deselected() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + h.state_mut().ui.layer_filter_by = crate::layer_filter_ui::FilterBy::Name; + h.state_mut().ui.layer_filter_name = "Pixels".into(); + h.run_steps(4); + // Shown again, the row is no longer highlighted: the selection went, it wasn't just hidden. + h.state_mut().ui.layer_filter_name.clear(); + h.run_steps(4); + assert!(!selected(&h, "Gaussian Blur")); + h.key_press(egui::Key::Delete); + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert!(st.doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself"); +} + +/// With the Layers panel closed the selected row isn't on screen either, so Delete deletes the +/// active layer as usual. +#[test] +fn with_the_layers_panel_closed_delete_leaves_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "window.toggle.layers", json!({})).unwrap(); + h.run_steps(4); + assert!(h.query_all_by_label("Gaussian Blur").next().is_none(), "the panel is closed"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert!(h.state().session.active().unwrap().doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself"); +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 7a7f8705e0..df6ce33d24 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -169,6 +169,12 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: open_editor(app, ui.ctx(), l.id.0, sm, i); } } + // Every row of `l` is drawn, so a filter of it selected (now, after this pass's clicks) is on + // screen: see [`forget_hidden`]. + if ui.ctx().data(|d| d.get_temp::(selected_key(doc))).is_some_and(|(layer, _)| layer == l.id.0) { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| d.insert_temp(drawn_key(doc), pass)); + } } /// Smart filter rows being dragged: the smart object and the filter index (`None` = the Smart @@ -184,12 +190,22 @@ fn selected_key(doc: photocraft_doc::DocId) -> egui::Id { egui::Id::new(("smart-filter-selected", doc)) } -/// The selected smart filter row, while its smart object is active and the filter still exists. +/// The pass in which the selected smart filter row of a document was last drawn. +fn drawn_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("smart-filter-selected-drawn", doc)) +} + +/// The selected smart filter row, while its smart object is active, the filter still exists and +/// its row is on screen (drawn this pass or the last: the Layers panel may be closed). pub(crate) fn selected(ctx: &egui::Context, doc: &Document, active: Option) -> Option { let (layer, filter) = ctx.data(|d| d.get_temp::(selected_key(doc.id)))?; if active != Some(LayerId(layer)) { return None; } + let drawn = ctx.data(|d| d.get_temp::(drawn_key(doc.id)))?; + if drawn + 1 < ctx.cumulative_pass_nr() { + return None; + } let LayerContent::Smart(sm) = &doc.layer(LayerId(layer))?.content else { return None }; let exists = match filter { Some(i) => i < sm.smart_filters.len(), @@ -204,6 +220,17 @@ pub(crate) fn deselect(ctx: &egui::Context, doc: photocraft_doc::DocId) { ctx.data_mut(|d| d.remove::(selected_key(doc))); } +/// Forgets the selected smart filter row when the Layers panel didn't draw it this pass (its smart +/// object is in a closed group, filtered out or isolated away): Delete must never remove a filter +/// nobody sees highlighted. +pub(crate) fn forget_hidden(ctx: &egui::Context, doc: photocraft_doc::DocId) { + let picked = ctx.data(|d| d.get_temp::(selected_key(doc))); + let drawn = ctx.data(|d| d.get_temp::(drawn_key(doc))); + if picked.is_some() && drawn != Some(ctx.cumulative_pass_nr()) { + deselect(ctx, doc); + } +} + /// While smart filters are dragged: their name by the pointer, and the copy cursor while ⌥ is held. pub(crate) fn drag_feedback(ctx: &egui::Context, doc: &Document) { let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; @@ -299,6 +326,36 @@ pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) { } } +/// Delete or Backspace with no modifiers while a filter's row is selected removes that filter (the +/// Smart Filters row: all of them), as the panel's Delete button does (#4751), instead of +/// deleting the smart object or reaching Edit › Clear. +pub(crate) fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them (#1534). + if app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { + return false; + } + let Some(st) = app.session.active() else { return false }; + let Some(picked) = selected(ctx, &st.doc, st.active_layer) else { return false }; + let doc = st.doc.id; + // Only where the key would delete the smart object: Quick Mask, a single channel, a targeted + // mask or a selection keep their own Delete. + if crate::shortcut_dispatch::delete_key_command(app, "edit.clear".into()) != "layer.delete" { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + let (command, params) = trash_action(picked); + deselect(ctx, doc); + if let Err(e) = app.run(&command, params) { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { @@ -319,6 +376,14 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } + // And Liquify, which re-opens with the filter's recorded strokes. + if f.command == photocraft_engine::distort_cmds::LIQUIFY { + if let Err(e) = crate::liquify_ui::open_smart_filter(app, ctx, photocraft_doc::LayerId(layer), index) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } // An adjustment recorded on the smart object (Brightness/Contrast, Levels…) re-opens its own // Image › Adjustments editor, like the filters above. if crate::adjust_dialog::has_dialog(&f.command) { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 26208d2a43..0938a9c715 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -588,6 +588,7 @@ fn wraps(id: &str) -> bool { | "file.placeLinked" | "layer.smartObjects.replaceContents" | "layer.smartObjects.exportContents" + | "layer.smartObjects.relinkToFile" | "layer.smartObjects.convertToLinked" | "file.closeAll" | "file.closeOthers" @@ -1259,6 +1260,18 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) })) } "layer.smartObjects.exportContents" => Some(export_contents(app)), + "layer.smartObjects.relinkToFile" => { + let st = app.session.active()?; + let (doc, layer) = (st.doc.id, st.active_layer?); + // A link needs a file on disk: the desktop picker answers its full path, the web + // picker only a name and the bytes, which can't be linked to. + Some(app.pick_file_bytes(move |app, path, _| { + if !std::path::Path::new(&path).is_absolute() { + return Err(format!("{path}: Relink to File needs a file on disk")); + } + app.with_document(doc, |app| app.run("layer.smartObjects.relinkToFile", json!({"layer": layer.0, "path": path}))) + })) + } "layer.smartObjects.convertToLinked" => { let st = app.session.active()?; let (doc, layer) = (st.doc.id, st.active_layer?); diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 35b996deea..b577bf5861 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -1,12 +1,13 @@ //! Filter › Adaptive Wide Angle…: a full-window dialog like Photoshop's. Drag on the image to //! draw a constraint (the scene line between the two points, curved by the lens model); hold -//! Shift when releasing to make it horizontal or vertical; right-click a constraint to delete -//! it. The right panel holds the correction model, focal length, crop factor, scale and the +//! Shift when releasing to make it horizontal or vertical; right-click a constraint for its menu +//! (Free, Horizontal, Vertical, Delete), as in Photoshop. The right panel holds the correction model, focal length, crop factor, scale and the //! constraint list. The preview solves the mesh warp on a CPU proxy (≤ 800 px); OK runs //! `filter.adaptiveWideAngle` with the constraints in document pixels. //! //! Control channel: `ui.menu.invoke {"id":"filter.adaptiveWideAngle","params":{"ui":{"set":{…}, -//! "add":{"a":[x,y],"b":[x,y],"orientation":"…"}, "remove":i, "commit":true | "cancel":true}}}`. +//! "add":{"a":[x,y],"b":[x,y],"orientation":"…"}, "orient":{"index":i,"orientation":"…"}, "remove":i, +//! "commit":true | "cancel":true}}}`. use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::transform::Interp; @@ -40,6 +41,8 @@ pub struct WideAngleDialog { dirty: bool, pub preview: bool, drag: Option<([f64; 2], [f64; 2])>, + /// The constraint a right-click opened the constraint menu on. + menu_for: Option, pub residual: f64, pub render_ms: f64, history: EditHistory, @@ -143,6 +146,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> dirty: true, preview: true, drag: None, + menu_for: None, residual: 0.0, render_ms: 0.0, }; @@ -192,6 +196,12 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val d.params.constraints.push(Constraint { a: pa, b: pb, orientation: parse_orientation(a.get("orientation").and_then(Value::as_str).unwrap_or("free")) }); d.dirty = true; } + if let Some(o) = ui.get("orient") { + let i = o.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let Some(c) = i.and_then(|i| d.params.constraints.get_mut(i)) else { return Some(Err("`orient` needs the index of a constraint".into())) }; + c.orientation = parse_orientation(o.get("orientation").and_then(Value::as_str).unwrap_or("free")); + d.dirty = true; + } if let Some(i) = ui.get("remove").and_then(Value::as_u64) && (i as usize) < d.params.constraints.len() { @@ -217,6 +227,32 @@ fn commit(app: &mut PhotocraftApp) -> Result { app.run("filter.adaptiveWideAngle", d.command_params()) } +/// Photoshop's right-click menu on a constraint: its orientation (the current one checked) and +/// Delete. A right-click away from every constraint opens nothing. +fn constraint_menu(ui: &mut egui::Ui, d: &mut WideAngleDialog) { + let Some(i) = d.menu_for.filter(|i| *i < d.params.constraints.len()) else { + ui.close(); + return; + }; + for (o, label) in [(Orientation::Free, tl!("Free")), (Orientation::Horizontal, tl!("Horizontal")), (Orientation::Vertical, tl!("Vertical"))] { + let current = d.params.constraints.get(i).is_some_and(|c| c.orientation == o); + if ui.radio(current, label).clicked() { + if let Some(c) = d.params.constraints.get_mut(i) { + c.orientation = o; + d.dirty = true; + } + ui.close(); + } + } + ui.separator(); + if ui.button(tl!("Delete")).clicked() { + d.params.constraints.remove(i); + d.menu_for = None; + d.dirty = true; + ui.close(); + } +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.wide_angle.is_none() { return; @@ -274,7 +310,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { - // Delete the constraint whose curve passes nearest the click. + // The constraint whose curve passes nearest the click gets the menu. let near = d .params .constraints @@ -282,13 +318,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { .enumerate() .map(|(i, c)| (i, cam.arc(c.a, c.b, 24).iter().map(|q| (to_screen(*q) - p).length()).fold(f32::MAX, f32::min))) .min_by(|a, b| a.1.total_cmp(&b.1)); - if let Some((i, dist)) = near - && dist < 10.0 - { - d.params.constraints.remove(i); - d.dirty = true; - } + d.menu_for = near.filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); } + resp.context_menu(|ui| constraint_menu(ui, d)); let show_src = !d.preview || d.drag.is_some(); let tex = if show_src { d.src_tex.as_ref() } else { d.out_tex.as_ref() }; widgets::checker(&painter, img_r, 8.0); @@ -393,9 +425,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::hairline(ui); widgets::checkbox(ui, &mut d.preview, tl!("Preview")); ui.label( - egui::RichText::new(tl!("Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete.")) - .color(t.text_faint) - .size(11.0), + egui::RichText::new(tl!( + "Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it." + )) + .color(t.text_faint) + .size(11.0), ); }); let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max); @@ -458,6 +492,74 @@ mod tests { assert!(app.wide_angle.is_none()); } + /// A 160 × 100 grey layer to open the dialog on. + fn grey_layer() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 160, "height": 100})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#808080"})).unwrap(); + app + } + + /// Open the dialog with one free constraint across the middle of the layer. + fn open_with_a_constraint(app: &mut PhotocraftApp, ctx: &egui::Context) { + menu(app, ctx, "filter.adaptiveWideAngle", &json!({"ui": {"add": {"a": [20, 50], "b": [140, 50]}}})).unwrap().unwrap(); + } + + /// `orient` sets a constraint's orientation, as the right-click menu does; a bad index is + /// an error that changes nothing. + #[test] + fn orient_sets_a_constraint_orientation() { + let mut app = grey_layer(); + let ctx = egui::Context::default(); + open_with_a_constraint(&mut app, &ctx); + let r = menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"orient": {"index": 0, "orientation": "vertical"}}})).unwrap().unwrap(); + assert_eq!(r["params"]["constraints"][0]["orientation"], "vertical"); + for bad in [json!({"index": 1, "orientation": "horizontal"}), json!({"orientation": "horizontal"}), json!({"index": -1})] { + assert!(menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"orient": bad}})).unwrap().is_err(), "{bad}"); + } + assert_eq!(app.wide_angle.as_ref().unwrap().params.constraints[0].orientation, Orientation::Vertical); + } + + /// Right-clicking a constraint opens Photoshop's constraint menu: an orientation (the + /// current one checked) or Delete. A right-click away from every constraint deletes nothing + /// and opens no menu (it used to delete the nearest constraint outright). + #[test] + fn right_click_on_a_constraint_opens_its_menu() { + use egui_kittest::kittest::Queryable; + let size = vec2(1000.0, 600.0); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(|ui, app: &mut PhotocraftApp| show(app, ui.ctx()), grey_layer()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + open_with_a_constraint(h.state_mut(), &ctx); + h.run_steps(2); + // The image view (see `show`): the window less the title, footer and panel, inset 16 pt. + let view = ERect::from_min_max(pos2(16.0, 30.0 + 16.0), pos2(size.x - PANEL_W - 16.0, size.y - 48.0 - 16.0)); + let right_click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: Pos2| { + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(p), button(true)]); + h.run_steps(1); + h.input_mut().events.push(button(false)); + h.run_steps(2); + }; + let constraints = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().wide_angle.as_ref().unwrap().params.constraints.clone(); + // The constraint runs through the document's centre, which the view centres. + right_click(&mut h, view.center()); + h.get_by_label("Horizontal").click(); + h.run_steps(2); + assert_eq!(constraints(&h).len(), 1, "choosing an orientation keeps the constraint"); + assert_eq!(constraints(&h)[0].orientation, Orientation::Horizontal); + // Away from it: no menu, nothing deleted. + right_click(&mut h, view.left_top() + vec2(4.0, 4.0)); + assert!(h.query_by_label("Horizontal").is_none(), "no menu away from a constraint"); + assert_eq!(constraints(&h).len(), 1); + // Delete from the menu (the panel's list has its own Delete button too). + right_click(&mut h, view.center()); + h.get_all_by_label("Delete").last().expect("the menu's Delete").click(); + h.run_steps(2); + assert!(constraints(&h).is_empty()); + } + #[test] fn command_z_steps_the_dialog_not_the_document() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/tests/radial_blur_center.rs b/crates/ui-egui/tests/radial_blur_center.rs new file mode 100644 index 0000000000..8fc14ab7c7 --- /dev/null +++ b/crates/ui-egui/tests/radial_blur_center.rs @@ -0,0 +1,237 @@ +//! Radial Blur's Zoom diagram shares the dialog's centre coordinates with its sliders, +//! numeric fields, automation, preview and final command. Uses the real dialog through +//! the public app/control APIs, like custom_print_paper.rs; no GPU adapter is needed. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape, Vec2, accesskit::Role, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_ui_egui::{ + PhotocraftApp, + control::{ControlRequest, Outcome, handle}, + dialogs, filter_dialog, i18n, + theme::ThemeKind, +}; +use serde_json::{Map, Value, json}; + +const COMMAND: &str = "filter.blur.radialBlur"; + +fn harness() -> Harness<'static, PhotocraftApp> { + themed_harness(ThemeKind::Pro, vec2(1000.0, 800.0)) +} + +fn themed_harness(theme: ThemeKind, size: Vec2) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("paint.stroke", json!({"points": [[8, 8, 1]], "size": 4, "color": "#ff8000"})).unwrap(); + let mut h = Harness::builder().with_size(size).build_ui_state( + |ui, app| { + let _language = i18n::language_scope(i18n::Lang::EN); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + control(&mut h, "ui.menu.invoke", json!({"id": COMMAND})); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": "method", "value": "zoom"})); + h +} + +fn control(h: &mut Harness<'_, PhotocraftApp>, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (request, _rx) = ControlRequest::new(method, params); + let result = handle(h.state_mut(), &ctx, &request); + assert!(matches!(result, Outcome::Done(v) if v["ok"] == true), "{method} succeeds"); + h.run_steps(3); +} + +fn fields<'a>(h: &'a Harness<'_, PhotocraftApp>) -> &'a Map { + &h.state().ui.dialogs.last().unwrap().fields +} + +fn center(h: &Harness<'_, PhotocraftApp>) -> [f64; 2] { + [fields(h)["centerX"].as_f64().unwrap(), fields(h)["centerY"].as_f64().unwrap()] +} + +fn diagram(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.get_by_role_and_label(Role::Slider, "Center").rect() +} + +fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, x: f32, y: f32) { + let plot = diagram(h).shrink(4.0); + let pos = plot.min + vec2(x, y) * plot.size(); + pointer(h, pos, true); + pointer(h, pos, false); + h.run_steps(2); +} + +fn marker(shape: &Shape, rect: Rect) -> Option { + match shape { + Shape::Circle(c) if c.radius == 3.0 && rect.contains(c.center) => Some(c.center), + Shape::Vec(shapes) => shapes.iter().find_map(|s| marker(s, rect)), + _ => None, + } +} + +fn assert_marker(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) { + let rect = diagram(h); + let plot = rect.shrink(4.0); + let expected = plot.min + vec2(x, y) * plot.size(); + let actual = h.output().shapes.iter().find_map(|s| marker(&s.shape, rect)).expect("painted centre marker"); + assert!(actual.distance(expected) < 0.1, "marker at {actual:?}, expected {expected:?}"); +} + +#[test] +fn clicks_and_drags_update_both_coordinates_and_clamp_at_the_edges() { + let mut h = harness(); + click(&mut h, 0.25, 0.75); + assert_eq!(center(&h), [0.25, 0.75]); + assert_marker(&h, 0.25, 0.75); + let plot = diagram(&h).shrink(4.0); + pointer(&mut h, plot.min + plot.size() * 0.5, true); + assert_eq!(center(&h), [0.5, 0.5], "the press itself moves the centre"); + for (point, expected) in [(vec2(0.8, 0.2), [0.8, 0.2]), (vec2(1.4, -0.4), [1.0, 0.0]), (vec2(-0.4, 1.4), [0.0, 1.0])] { + h.event(Event::PointerMoved(plot.min + plot.size() * point)); + h.step(); + assert_eq!(center(&h), expected, "updates during the drag, before release"); + assert_marker(&h, expected[0] as f32, expected[1] as f32); + } + pointer(&mut h, plot.left_bottom(), false); + h.run_steps(2); + assert_eq!(center(&h), [0.0, 1.0]); +} + +#[test] +fn slider_and_numeric_edits_move_the_diagram() { + let mut h = harness(); + let slider = h.get_by_role_and_label(Role::Slider, "Center X").rect(); + let at = |fraction| pos2(slider.left() + 7.0 + (slider.width() - 14.0) * fraction, slider.center().y); + pointer(&mut h, at(0.15), true); + h.event(Event::PointerMoved(at(0.8))); + h.step(); + pointer(&mut h, at(0.8), false); + h.run_steps(2); + assert_eq!(center(&h), [0.8, 0.5]); + assert_marker(&h, 0.8, 0.5); + h.get_all_by_role(Role::SpinButton).nth(2).expect("Center Y numeric field").click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text("0.23".into())); + h.key_press(Key::Tab); + h.run_steps(3); + assert_eq!(center(&h), [0.8, 0.23]); + assert_marker(&h, 0.8, 0.23); +} + +#[test] +fn keyboard_steps_and_automation_share_the_diagram_coordinates() { + let mut h = harness(); + click(&mut h, 1.0, 0.0); + h.key_press(Key::ArrowRight); + h.key_press(Key::ArrowUp); + h.run_steps(2); + assert_eq!(center(&h), [1.0, 0.0]); + h.key_press(Key::ArrowLeft); + h.key_press(Key::ArrowDown); + h.run_steps(2); + assert_eq!(center(&h), [0.99, 0.01]); + let id = h.state().ui.dialogs.last().unwrap().id; + for (field, value) in [("centerX", 0.12), ("centerY", 0.87)] { + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": field, "value": value})); + } + h.run_steps(3); + assert_marker(&h, 0.12, 0.87); +} + +#[test] +fn only_zoom_has_the_diagram_and_switching_methods_keeps_the_center() { + let mut h = harness(); + click(&mut h, 0.2, 0.7); + for method in ["spin", "zoom"] { + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("method".into(), json!(method)); + h.run_steps(3); + assert_eq!(h.query_by_role_and_label(Role::Slider, "Center").is_some(), method == "zoom"); + let [x, y] = center(&h); + assert!((x - 0.2).abs() < 1e-6 && (y - 0.7).abs() < 1e-6); + assert_eq!(h.get_all_by_role(Role::SpinButton).count(), 3, "no duplicated centre controls"); + } + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.cancel", json!({"dialog": id})); + control(&mut h, "ui.menu.invoke", json!({"id": "filter.blur.gaussianBlur"})); + assert!(h.query_by_role_and_label(Role::Slider, "Center").is_none(), "other filters keep their existing controls"); +} + +#[test] +fn editing_the_diagram_is_only_a_preview_until_confirmed() { + let mut h = harness(); + let revision = h.state().session.active().unwrap().revision; + click(&mut h, 0.25, 0.75); + assert_eq!(h.state().session.active().unwrap().revision, revision); + let before = h.state().session.active().unwrap().doc.clone(); + let params = filter_dialog::params_of(fields(&h)); + let active_layer = h.state().session.active().unwrap().active_layer; + let expected = filter_dialog::preview_document(&before, active_layer, COMMAND, ¶ms, 1).unwrap(); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.confirm", json!({"dialog": id})); + let actual = &h.state().session.active().unwrap().doc; + assert_eq!(photocraft_compose::render(actual, actual.bounds()), photocraft_compose::render(&expected, expected.bounds())); + assert!(h.state().session.active().unwrap().revision > revision); + control(&mut h, "ui.menu.invoke", json!({"id": COMMAND})); + let id = h.state().ui.dialogs.last().unwrap().id; + let revision = h.state().session.active().unwrap().revision; + click(&mut h, 0.8, 0.2); + control(&mut h, "ui.dialog.cancel", json!({"dialog": id})); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} + +#[test] +fn hostile_coordinates_are_bounded_before_drawing() { + let mut h = harness(); + for (x, y, expected) in [(json!(1e100), json!(-1e100), [1.0, 0.0]), (json!("invalid"), Value::Null, [0.5, 0.5])] { + let fields = &mut h.state_mut().ui.dialogs.last_mut().unwrap().fields; + fields.insert("centerX".into(), x); + fields.insert("centerY".into(), y); + h.run_steps(3); + assert_eq!(center(&h), expected); + assert_marker(&h, expected[0] as f32, expected[1] as f32); + } +} + +#[test] +fn diagram_and_sliders_fit_every_theme_and_amount_changes_the_rays() { + for theme in ThemeKind::ALL { + for width in [560.0, 1000.0] { + let mut h = themed_harness(theme, vec2(width, 800.0)); + click(&mut h, 0.25, 0.75); + let diagram = h.get_by_role_and_label(Role::Slider, "Center").rect(); + let viewport = Rect::from_min_size(Pos2::ZERO, vec2(width, 800.0)); + assert!(viewport.contains_rect(diagram), "{theme:?}: the diagram fits at {width} pt"); + for label in ["Center X", "Center Y"] { + let slider = h.get_by_role_and_label(Role::Slider, label).rect(); + assert!(slider.left() > diagram.right(), "{theme:?}: {label} is beside the diagram"); + assert!(viewport.contains_rect(slider), "{theme:?}: {label} fits at {width} pt"); + } + let rays = |h: &Harness<'_, PhotocraftApp>| { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } if points.iter().all(|p| diagram.contains(*p)) => Some(*points), + _ => None, + }) + .collect::>() + }; + let before = rays(&h); + assert!(!before.is_empty(), "the diagram paints the streaks"); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": "amount", "value": 75})); + assert_ne!(before, rays(&h), "Amount changes the schematic streaks"); + assert_eq!(center(&h), [0.25, 0.75]); + } + } +} diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 4759fafd95..6663cf0b00 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -85,7 +85,7 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | Implemented (canvas_tool_menu.rs `PASTEBOARD_MENU`, #2462): Default, Black, Dark Gray, Medium Gray, Light Gray, Custom, Select Custom Color… | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | | Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions live in the Type tool's edit menu (canvas_tool_menu.rs), not in generic fields, which hold no type layer text (UI-217-8) | P1; **inferred** exact spelling list | | Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label | -| Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | +| Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Right-click a constraint (wide_angle_ui.rs `constraint_menu`): Free, Horizontal, Vertical (current checked), Delete; a right-click away from every constraint opens nothing. Photoshop's Arbitrary angle is not modelled by the solver | Done except Arbitrary: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | ## Delivery slices and acceptance diff --git a/docs/ui-design.md b/docs/ui-design.md index d0ee20f7bb..8ac01f8e01 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -61,6 +61,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | +| Radial Blur: Zoom | `filter_dialog/zoom_center.rs` | A ray diagram to the left of Center X/Y sets the centre by click or drag. Diagram, sliders and numeric fields share the same coordinates; arrow keys on the diagram step by 0.01. Ray lengths follow Amount, and the existing image preview follows every edit. OK applies the filter; Cancel discards the edits. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | Shape Stroke Options (updated 2026-10-11) sizes to its content and scrolls only when necessary. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cd5be57534..86c93dd1c7 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-10-21 · **Change:** minor (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; 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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (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; 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) · **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, @@ -228,7 +228,7 @@ Paragraph; the rest open as in-app floating windows. | 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 | | Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | -| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | +| 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` | @@ -279,3 +279,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | | 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. | | 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) | +| 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) |